1 // This Source Code Form is subject to the terms of the Mozilla Public
2 // License, v. 2.0. If a copy of the MPL was not distributed with this
3 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
7 this.EXPORTED_SYMBOLS = ["UITour", "UITourMetricsProvider"];
9 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
11 Cu.import("resource://gre/modules/Services.jsm");
12 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
13 Cu.import("resource://gre/modules/Promise.jsm");
14 Cu.import("resource://gre/modules/Task.jsm");
16 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
17 "resource://gre/modules/LightweightThemeManager.jsm");
18 XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
19 "resource://gre/modules/PermissionsUtils.jsm");
20 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
21 "resource:///modules/CustomizableUI.jsm");
22 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
23 "resource://gre/modules/UITelemetry.jsm");
24 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
25 "resource:///modules/BrowserUITelemetry.jsm");
26 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
27 "resource://gre/modules/Metrics.jsm");
29 const UITOUR_PERMISSION = "uitour";
30 const PREF_PERM_BRANCH = "browser.uitour.";
31 const PREF_SEENPAGEIDS = "browser.uitour.seenPageIDs";
32 const MAX_BUTTONS = 4;
34 const BUCKET_NAME = "UITour";
35 const BUCKET_TIMESTEPS = [
36 1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
37 3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
38 10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
39 60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
42 // Time after which seen Page IDs expire.
43 const SEENPAGEID_EXPIRY = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.
45 // Prefix for any target matching a search engine.
46 const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
52 pageIDSourceTabs: new WeakMap(),
53 pageIDSourceWindows: new WeakMap(),
54 /* Map from browser windows to a set of tabs in which a tour is open */
55 originTabs: new WeakMap(),
56 /* Map from browser windows to a set of pinned tabs opened by (a) tour(s) */
57 pinnedTabs: new WeakMap(),
58 urlbarCapture: new WeakMap(),
59 appMenuOpenForAnnotation: new Set(),
60 availableTargetsCache: new WeakMap(),
63 _annotationPanelMutationObservers: new WeakMap(),
67 highlightEffects: ["random", "wobble", "zoom", "color"],
70 query: (aDocument) => {
71 let statusButton = aDocument.getElementById("PanelUI-fxa-status");
72 return aDocument.getAnonymousElementByAttribute(statusButton,
74 "toolbarbutton-icon");
76 widgetName: "PanelUI-fxa-status",
78 ["addons", {query: "#add-ons-button"}],
80 addTargetListener: (aDocument, aCallback) => {
81 let panelPopup = aDocument.getElementById("PanelUI-popup");
82 panelPopup.addEventListener("popupshown", aCallback);
84 query: "#PanelUI-button",
85 removeTargetListener: (aDocument, aCallback) => {
86 let panelPopup = aDocument.getElementById("PanelUI-popup");
87 panelPopup.removeEventListener("popupshown", aCallback);
91 query: "#back-button",
92 widgetName: "urlbar-container",
94 ["bookmarks", {query: "#bookmarks-menu-button"}],
96 query: (aDocument) => {
97 let customizeButton = aDocument.getElementById("PanelUI-customize");
98 return aDocument.getAnonymousElementByAttribute(customizeButton,
100 "toolbarbutton-icon");
102 widgetName: "PanelUI-customize",
104 ["help", {query: "#PanelUI-help"}],
105 ["home", {query: "#home-button"}],
106 ["loop", {query: "#loop-button-throttled"}],
108 query: "#panic-button",
109 widgetName: "panic-button",
111 ["privateWindow", {query: "#privatebrowsing-button"}],
112 ["quit", {query: "#PanelUI-quit"}],
115 widgetName: "search-container",
118 query: (aDocument) => {
119 let searchbar = aDocument.getElementById("searchbar");
120 if (searchbar.hasAttribute("oneoffui")) {
123 return aDocument.getAnonymousElementByAttribute(searchbar,
125 "searchbar-engine-button");
127 widgetName: "search-container",
130 query: (aDocument) => {
131 let searchbar = aDocument.getElementById("searchbar");
132 if (!searchbar.hasAttribute("oneoffui")) {
135 return aDocument.getAnonymousElementByAttribute(searchbar,
137 "searchbar-search-button");
139 widgetName: "search-container",
141 ["searchPrefsLink", {
142 query: (aDocument) => {
144 let searchbar = aDocument.getElementById("searchbar");
145 if (searchbar.hasAttribute("oneoffui")) {
146 let popup = aDocument.getElementById("PopupSearchAutoComplete");
147 if (popup.state != "open")
149 element = aDocument.getAnonymousElementByAttribute(popup,
153 element = aDocument.getAnonymousElementByAttribute(searchbar,
155 "open-engine-manager");
157 if (!element || !UITour.isElementVisible(element)) {
163 ["selectedTabIcon", {
164 query: (aDocument) => {
165 let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
166 let element = aDocument.getAnonymousElementByAttribute(selectedtab,
169 if (!element || !UITour.isElementVisible(element)) {
177 widgetName: "urlbar-container",
182 // Lazy getter is initialized here so it can be replicated any time
184 delete this.seenPageIDs;
185 Object.defineProperty(this, "seenPageIDs", {
186 get: this.restoreSeenPageIDs.bind(this),
191 XPCOMUtils.defineLazyGetter(this, "url", function () {
192 return Services.urlFormatter.formatURLPref("browser.uitour.url");
195 // Clear the availableTargetsCache on widget changes.
196 let listenerMethods = [
203 CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
204 listener[method] = () => this.availableTargetsCache.clear();
209 restoreSeenPageIDs: function() {
210 delete this.seenPageIDs;
212 if (UITelemetry.enabled) {
213 let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;
216 let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
217 data = new Map(JSON.parse(data));
219 for (let [pageID, details] of data) {
221 if (typeof pageID != "string" ||
222 typeof details != "object" ||
223 typeof details.lastSeen != "number" ||
224 details.lastSeen < dateThreshold) {
230 this.seenPageIDs = data;
234 if (!this.seenPageIDs)
235 this.seenPageIDs = new Map();
237 this.persistSeenIDs();
239 return this.seenPageIDs;
242 addSeenPageID: function(aPageID) {
243 if (!UITelemetry.enabled)
246 this.seenPageIDs.set(aPageID, {
247 lastSeen: Date.now(),
250 this.persistSeenIDs();
253 persistSeenIDs: function() {
254 if (this.seenPageIDs.size === 0) {
255 Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
259 Services.prefs.setCharPref(PREF_SEENPAGEIDS,
260 JSON.stringify([...this.seenPageIDs]));
263 onPageEvent: function(aEvent) {
264 let contentDocument = null;
265 if (aEvent.target instanceof Ci.nsIDOMHTMLDocument)
266 contentDocument = aEvent.target;
267 else if (aEvent.target instanceof Ci.nsIDOMHTMLElement)
268 contentDocument = aEvent.target.ownerDocument;
272 // Ignore events if they're not from a trusted origin.
273 if (!this.ensureTrustedOrigin(contentDocument))
276 if (typeof aEvent.detail != "object")
279 let action = aEvent.detail.action;
280 if (typeof action != "string" || !action)
283 let data = aEvent.detail.data;
284 if (typeof data != "object")
287 let window = this.getChromeWindow(contentDocument);
288 // Do this before bailing if there's no tab, so later we can pick up the pieces:
289 window.gBrowser.tabContainer.addEventListener("TabSelect", this);
290 let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
292 // This should only happen while detaching a tab:
293 if (this._detachingTab) {
294 this._queuedEvents.push(aEvent);
295 this._pendingDoc = Cu.getWeakReference(contentDocument);
298 Cu.reportError("Discarding tabless UITour event (" + action + ") while not detaching a tab." +
299 "This shouldn't happen!");
304 case "registerPageID": {
305 // This is only relevant if Telemtry is enabled.
306 if (!UITelemetry.enabled)
309 // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
310 // pageID, as it could make parsing the telemetry bucket name difficult.
311 if (typeof data.pageID == "string" &&
312 !data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
313 this.addSeenPageID(data.pageID);
315 // Store tabs and windows separately so we don't need to loop over all
316 // tabs when a window is closed.
317 this.pageIDSourceTabs.set(tab, data.pageID);
318 this.pageIDSourceWindows.set(window, data.pageID);
320 this.setTelemetryBucket(data.pageID);
325 case "showHighlight": {
326 let targetPromise = this.getTarget(window, data.target);
327 targetPromise.then(target => {
329 Cu.reportError("UITour: Target could not be resolved: " + data.target);
332 let effect = undefined;
333 if (this.highlightEffects.indexOf(data.effect) !== -1) {
334 effect = data.effect;
336 this.showHighlight(target, effect);
337 }).then(null, Cu.reportError);
341 case "hideHighlight": {
342 this.hideHighlight(window);
347 let targetPromise = this.getTarget(window, data.target, true);
348 targetPromise.then(target => {
350 Cu.reportError("UITour: Target could not be resolved: " + data.target);
355 if (typeof data.icon == "string")
356 iconURL = this.resolveURL(contentDocument, data.icon);
359 if (Array.isArray(data.buttons) && data.buttons.length > 0) {
360 for (let buttonData of data.buttons) {
361 if (typeof buttonData == "object" &&
362 typeof buttonData.label == "string" &&
363 typeof buttonData.callbackID == "string") {
365 label: buttonData.label,
366 callbackID: buttonData.callbackID,
369 if (typeof buttonData.icon == "string")
370 button.iconURL = this.resolveURL(contentDocument, buttonData.icon);
372 if (typeof buttonData.style == "string")
373 button.style = buttonData.style;
375 buttons.push(button);
377 if (buttons.length == MAX_BUTTONS)
383 let infoOptions = {};
385 if (typeof data.closeButtonCallbackID == "string")
386 infoOptions.closeButtonCallbackID = data.closeButtonCallbackID;
387 if (typeof data.targetCallbackID == "string")
388 infoOptions.targetCallbackID = data.targetCallbackID;
390 this.showInfo(contentDocument, target, data.title, data.text, iconURL, buttons, infoOptions);
391 }).then(null, Cu.reportError);
396 this.hideInfo(window);
400 case "previewTheme": {
401 this.previewTheme(data.theme);
410 case "addPinnedTab": {
411 this.ensurePinnedTab(window, true);
415 case "removePinnedTab": {
416 this.removePinnedTab(window);
421 this.showMenu(window, data.name, () => {
422 if (typeof data.showCallbackID == "string")
423 this.sendPageCallback(contentDocument, data.showCallbackID);
429 this.hideMenu(window, data.name);
433 case "startUrlbarCapture": {
434 if (typeof data.text != "string" || !data.text ||
435 typeof data.url != "string" || !data.url) {
441 uri = Services.io.newURI(data.url, null, null);
446 let secman = Services.scriptSecurityManager;
447 let principal = contentDocument.nodePrincipal;
448 let flags = secman.DISALLOW_INHERIT_PRINCIPAL;
450 secman.checkLoadURIWithPrincipal(principal, uri, flags);
455 this.startUrlbarCapture(window, data.text, data.url);
459 case "endUrlbarCapture": {
460 this.endUrlbarCapture(window);
464 case "getConfiguration": {
465 if (typeof data.configuration != "string") {
469 this.getConfiguration(contentDocument, data.configuration, data.callbackID);
473 case "showFirefoxAccounts": {
474 // 'signup' is the only action that makes sense currently, so we don't
475 // accept arbitrary actions just to be safe...
476 // We want to replace the current tab.
477 contentDocument.location.href = "about:accounts?action=signup";
481 case "addNavBarWidget": {
482 // Add a widget to the toolbar
483 let targetPromise = this.getTarget(window, data.name);
484 targetPromise.then(target => {
485 this.addNavBarWidget(target, contentDocument, data.callbackID);
486 }).then(null, Cu.reportError);
490 case "setDefaultSearchEngine": {
491 let enginePromise = this.selectSearchEngine(data.identifier);
492 enginePromise.catch(Cu.reportError);
496 case "setTreatmentTag": {
497 let name = data.name;
498 let value = data.value;
499 let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
501 Services.prefs.setComplexValue("browser.uitour.treatment." + name,
502 Ci.nsISupportsString, string);
503 UITourHealthReport.recordTreatmentTag(name, value);
507 case "getTreatmentTag": {
508 let name = data.name;
511 value = Services.prefs.getComplexValue("browser.uitour.treatment." + name,
512 Ci.nsISupportsString).data;
514 this.sendPageCallback(contentDocument, data.callbackID, { value: value });
518 case "setSearchTerm": {
519 let targetPromise = this.getTarget(window, "search");
520 targetPromise.then(target => {
521 let searchbar = target.node;
522 searchbar.value = data.term;
523 searchbar.inputChanged();
524 }).then(null, Cu.reportError);
528 case "openSearchPanel": {
529 let targetPromise = this.getTarget(window, "search");
530 targetPromise.then(target => {
531 let searchbar = target.node;
533 if (searchbar.textbox.open) {
534 this.sendPageCallback(contentDocument, data.callbackID);
536 let onPopupShown = () => {
537 searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown);
538 this.sendPageCallback(contentDocument, data.callbackID);
541 searchbar.textbox.popup.addEventListener("popupshown", onPopupShown);
542 searchbar.openSuggestionsPanel();
544 }).then(null, Cu.reportError);
549 if (!this.originTabs.has(window))
550 this.originTabs.set(window, new Set());
552 this.originTabs.get(window).add(tab);
553 tab.addEventListener("TabClose", this);
554 tab.addEventListener("TabBecomingWindow", this);
555 window.addEventListener("SSWindowClosing", this);
560 handleEvent: function(aEvent) {
561 switch (aEvent.type) {
563 let window = this.getChromeWindow(aEvent.target);
564 this.teardownTour(window);
568 case "TabBecomingWindow":
569 this._detachingTab = true;
572 let tab = aEvent.target;
573 if (this.pageIDSourceTabs.has(tab)) {
574 let pageID = this.pageIDSourceTabs.get(tab);
576 // Delete this from the window cache, so if the window is closed we
577 // don't expire this page ID twice.
578 let window = tab.ownerDocument.defaultView;
579 if (this.pageIDSourceWindows.get(window) == pageID)
580 this.pageIDSourceWindows.delete(window);
582 this.setExpiringTelemetryBucket(pageID, "closed");
585 let window = tab.ownerDocument.defaultView;
586 this.teardownTour(window);
591 if (aEvent.detail && aEvent.detail.previousTab) {
592 let previousTab = aEvent.detail.previousTab;
594 if (this.pageIDSourceTabs.has(previousTab)) {
595 let pageID = this.pageIDSourceTabs.get(previousTab);
596 this.setExpiringTelemetryBucket(pageID, "inactive");
600 let window = aEvent.target.ownerDocument.defaultView;
601 let selectedTab = window.gBrowser.selectedTab;
602 let pinnedTab = this.pinnedTabs.get(window);
603 if (pinnedTab && pinnedTab.tab == selectedTab)
605 let originTabs = this.originTabs.get(window);
606 if (originTabs && originTabs.has(selectedTab))
610 if (this._detachingTab && this._pendingDoc && (pendingDoc = this._pendingDoc.get())) {
611 if (selectedTab.linkedBrowser.contentDocument == pendingDoc) {
612 if (!this.originTabs.get(window)) {
613 this.originTabs.set(window, new Set());
615 this.originTabs.get(window).add(selectedTab);
616 this.pendingDoc = null;
617 this._detachingTab = false;
618 while (this._queuedEvents.length) {
620 this.onPageEvent(this._queuedEvents.shift());
629 this.teardownTour(window);
633 case "SSWindowClosing": {
634 let window = aEvent.target;
635 if (this.pageIDSourceWindows.has(window)) {
636 let pageID = this.pageIDSourceWindows.get(window);
637 this.setExpiringTelemetryBucket(pageID, "closed");
640 this.teardownTour(window, true);
645 if (aEvent.target.id == "urlbar") {
646 let window = aEvent.target.ownerDocument.defaultView;
647 this.handleUrlbarInput(window);
654 setTelemetryBucket: function(aPageID) {
655 let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
656 BrowserUITelemetry.setBucket(bucket);
659 setExpiringTelemetryBucket: function(aPageID, aType) {
660 let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
661 BrowserUITelemetry.BUCKET_SEPARATOR + aType;
663 BrowserUITelemetry.setExpiringBucket(bucket,
667 // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
668 // can remain lazy-loaded on-demand.
669 getTelemetry: function() {
671 seenPageIDs: [...this.seenPageIDs.keys()],
675 teardownTour: function(aWindow, aWindowClosing = false) {
676 aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
677 aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hidePanelAnnotations);
678 aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hidePanelAnnotations);
679 aWindow.removeEventListener("SSWindowClosing", this);
681 let originTabs = this.originTabs.get(aWindow);
683 for (let tab of originTabs) {
684 tab.removeEventListener("TabClose", this);
685 tab.removeEventListener("TabBecomingWindow", this);
688 this.originTabs.delete(aWindow);
690 if (!aWindowClosing) {
691 this.hideHighlight(aWindow);
692 this.hideInfo(aWindow);
693 // Ensure the menu panel is hidden before calling recreatePopup so popup events occur.
694 this.hideMenu(aWindow, "appMenu");
697 this.endUrlbarCapture(aWindow);
698 this.removePinnedTab(aWindow);
702 getChromeWindow: function(aContentDocument) {
703 return aContentDocument.defaultView
705 .QueryInterface(Ci.nsIInterfaceRequestor)
706 .getInterface(Ci.nsIWebNavigation)
707 .QueryInterface(Ci.nsIDocShellTreeItem)
709 .QueryInterface(Ci.nsIInterfaceRequestor)
710 .getInterface(Ci.nsIDOMWindow)
714 importPermissions: function() {
716 PermissionsUtils.importFromPrefs(PREF_PERM_BRANCH, UITOUR_PERMISSION);
722 ensureTrustedOrigin: function(aDocument) {
723 if (aDocument.defaultView.top != aDocument.defaultView)
726 let uri = aDocument.documentURIObject;
728 if (uri.schemeIs("chrome"))
731 if (!this.isSafeScheme(uri))
734 this.importPermissions();
735 let permission = Services.perms.testPermission(uri, UITOUR_PERMISSION);
736 return permission == Services.perms.ALLOW_ACTION;
739 isSafeScheme: function(aURI) {
740 let allowedSchemes = new Set(["https", "about"]);
741 if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
742 allowedSchemes.add("http");
744 if (!allowedSchemes.has(aURI.scheme))
750 resolveURL: function(aDocument, aURL) {
752 let uri = Services.io.newURI(aURL, null, aDocument.documentURIObject);
754 if (!this.isSafeScheme(uri))
763 sendPageCallback: function(aDocument, aCallbackID, aData = {}) {
765 let detail = {data: aData, callbackID: aCallbackID};
766 detail = Cu.cloneInto(detail, aDocument.defaultView);
767 let event = new aDocument.defaultView.CustomEvent("mozUITourResponse", {
772 aDocument.dispatchEvent(event);
775 isElementVisible: function(aElement) {
776 let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
777 return (targetStyle.display != "none" && targetStyle.visibility == "visible");
780 getTarget: function(aWindow, aTargetName, aSticky = false) {
781 let deferred = Promise.defer();
782 if (typeof aTargetName != "string" || !aTargetName) {
783 deferred.reject("Invalid target name specified");
784 return deferred.promise;
787 if (aTargetName == "pinnedTab") {
789 targetName: aTargetName,
790 node: this.ensurePinnedTab(aWindow, aSticky)
792 return deferred.promise;
795 if (aTargetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
796 let engineID = aTargetName.slice(TARGET_SEARCHENGINE_PREFIX.length);
797 return this.getSearchEngineTarget(aWindow, engineID);
800 let targetObject = this.targets.get(aTargetName);
802 deferred.reject("The specified target name is not in the allowed set");
803 return deferred.promise;
806 let targetQuery = targetObject.query;
807 aWindow.PanelUI.ensureReady().then(() => {
809 if (typeof targetQuery == "function") {
811 node = targetQuery(aWindow.document);
816 node = aWindow.document.querySelector(targetQuery);
820 addTargetListener: targetObject.addTargetListener,
822 removeTargetListener: targetObject.removeTargetListener,
823 targetName: aTargetName,
824 widgetName: targetObject.widgetName,
825 allowAdd: targetObject.allowAdd,
827 }).then(null, Cu.reportError);
828 return deferred.promise;
831 targetIsInAppMenu: function(aTarget) {
832 let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
833 if (placement && placement.area == CustomizableUI.AREA_PANEL) {
837 let targetElement = aTarget.node;
838 // Use the widget for filtering if it exists since the target may be the icon inside.
839 if (aTarget.widgetName) {
840 targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
843 // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets.
844 return targetElement.id.startsWith("PanelUI-")
845 && targetElement.id != "PanelUI-button";
849 * Called before opening or after closing a highlight or info panel to see if
850 * we need to open or close the appMenu to see the annotation's anchor.
852 _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) {
853 // If the panel is in the desired state, we're done.
854 let panelIsOpen = aWindow.PanelUI.panel.state != "closed";
855 if (aShouldOpenForHighlight == panelIsOpen) {
861 // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead).
862 if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) {
868 if (aShouldOpenForHighlight) {
869 this.appMenuOpenForAnnotation.add(aAnnotationType);
871 this.appMenuOpenForAnnotation.delete(aAnnotationType);
874 // Actually show or hide the menu
875 if (this.appMenuOpenForAnnotation.size) {
876 this.showMenu(aWindow, "appMenu", aCallback);
878 this.hideMenu(aWindow, "appMenu");
885 previewTheme: function(aTheme) {
886 let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
887 let data = LightweightThemeManager.parseTheme(aTheme, origin);
889 LightweightThemeManager.previewTheme(data);
892 resetTheme: function() {
893 LightweightThemeManager.resetPreview();
896 ensurePinnedTab: function(aWindow, aSticky = false) {
897 let tabInfo = this.pinnedTabs.get(aWindow);
900 tabInfo.sticky = tabInfo.sticky || aSticky;
902 let url = Services.urlFormatter.formatURLPref("browser.uitour.pinnedTabUrl");
904 let tab = aWindow.gBrowser.addTab(url);
905 aWindow.gBrowser.pinTab(tab);
906 tab.addEventListener("TabClose", () => {
907 this.pinnedTabs.delete(aWindow);
914 this.pinnedTabs.set(aWindow, tabInfo);
920 removePinnedTab: function(aWindow) {
921 let tabInfo = this.pinnedTabs.get(aWindow);
923 aWindow.gBrowser.removeTab(tabInfo.tab);
927 * @param aTarget The element to highlight.
928 * @param aEffect (optional) The effect to use from UITour.highlightEffects or "none".
929 * @see UITour.highlightEffects
931 showHighlight: function(aTarget, aEffect = "none") {
932 let window = aTarget.node.ownerDocument.defaultView;
934 function showHighlightPanel() {
935 if (aTarget.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
936 // This won't affect normal higlights done via the panel, so we need to
937 // manually hide those.
938 this.hideHighlight(window);
939 aTarget.node.setAttribute("_moz-menuactive", true);
943 // Conversely, highlights for search engines are highlighted via CSS
944 // rather than a panel, so need to be manually removed.
945 this._hideSearchEngineHighlight(window);
947 let highlighter = aTarget.node.ownerDocument.getElementById("UITourHighlight");
949 let effect = aEffect;
950 if (effect == "random") {
951 // Exclude "random" from the randomly selected effects.
952 let randomEffect = 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1));
953 if (randomEffect == this.highlightEffects.length)
954 randomEffect--; // On the order of 1 in 2^62 chance of this happening.
955 effect = this.highlightEffects[randomEffect];
957 // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays.
958 highlighter.setAttribute("active", "none");
959 aTarget.node.ownerDocument.defaultView.getComputedStyle(highlighter).animationName;
960 highlighter.setAttribute("active", effect);
961 highlighter.parentElement.setAttribute("targetName", aTarget.targetName);
962 highlighter.parentElement.hidden = false;
965 // If the target is in the overflow panel, just highlight the overflow button.
966 if (aTarget.node.getAttribute("overflowedItem")) {
967 let doc = aTarget.node.ownerDocument;
968 let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
969 let areaNode = doc.getElementById(placement.area);
970 highlightAnchor = areaNode.overflowable._chevron;
972 highlightAnchor = aTarget.node;
974 let targetRect = highlightAnchor.getBoundingClientRect();
975 let highlightHeight = targetRect.height;
976 let highlightWidth = targetRect.width;
977 let minDimension = Math.min(highlightHeight, highlightWidth);
978 let maxDimension = Math.max(highlightHeight, highlightWidth);
980 // If the dimensions are within 200% of each other (to include the bookmarks button),
981 // make the highlight a circle with the largest dimension as the diameter.
982 if (maxDimension / minDimension <= 3.0) {
983 highlightHeight = highlightWidth = maxDimension;
984 highlighter.style.borderRadius = "100%";
986 highlighter.style.borderRadius = "";
989 highlighter.style.height = highlightHeight + "px";
990 highlighter.style.width = highlightWidth + "px";
992 // Close a previous highlight so we can relocate the panel.
993 if (highlighter.parentElement.state == "showing" || highlighter.parentElement.state == "open") {
994 highlighter.parentElement.hidePopup();
996 /* The "overlap" position anchors from the top-left but we want to centre highlights at their
998 let highlightWindow = aTarget.node.ownerDocument.defaultView;
999 let containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement);
1000 let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop);
1001 let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
1002 let highlightStyle = highlightWindow.getComputedStyle(highlighter);
1003 let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
1004 let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
1005 let offsetX = paddingTopPx
1006 - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
1007 let offsetY = paddingLeftPx
1008 - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
1009 this._addAnnotationPanelMutationObserver(highlighter.parentElement);
1010 highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY);
1013 // Prevent showing a panel at an undefined position.
1014 if (!this.isElementVisible(aTarget.node))
1017 this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight",
1018 this.targetIsInAppMenu(aTarget),
1019 showHighlightPanel.bind(this));
1022 hideHighlight: function(aWindow) {
1023 let tabData = this.pinnedTabs.get(aWindow);
1024 if (tabData && !tabData.sticky)
1025 this.removePinnedTab(aWindow);
1027 let highlighter = aWindow.document.getElementById("UITourHighlight");
1028 this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
1029 highlighter.parentElement.hidePopup();
1030 highlighter.removeAttribute("active");
1032 this._setAppMenuStateForAnnotation(aWindow, "highlight", false);
1033 this._hideSearchEngineHighlight(aWindow);
1036 _hideSearchEngineHighlight: function(aWindow) {
1037 // We special case highlighting items in the search engines dropdown,
1038 // so just blindly remove any highlight there.
1039 let searchMenuBtn = null;
1041 searchMenuBtn = this.targets.get("searchProvider").query(aWindow.document);
1042 } catch (e) { /* This is ok to fail. */ }
1043 if (searchMenuBtn) {
1044 let searchPopup = aWindow.document
1045 .getAnonymousElementByAttribute(searchMenuBtn,
1048 for (let menuItem of searchPopup.children)
1049 menuItem.removeAttribute("_moz-menuactive");
1054 * Show an info panel.
1056 * @param {Document} aContentDocument
1057 * @param {Node} aAnchor
1058 * @param {String} [aTitle=""]
1059 * @param {String} [aDescription=""]
1060 * @param {String} [aIconURL=""]
1061 * @param {Object[]} [aButtons=[]]
1062 * @param {Object} [aOptions={}]
1063 * @param {String} [aOptions.closeButtonCallbackID]
1065 showInfo: function(aContentDocument, aAnchor, aTitle = "", aDescription = "", aIconURL = "",
1066 aButtons = [], aOptions = {}) {
1067 function showInfoPanel(aAnchorEl) {
1070 let document = aAnchorEl.ownerDocument;
1071 let tooltip = document.getElementById("UITourTooltip");
1072 let tooltipTitle = document.getElementById("UITourTooltipTitle");
1073 let tooltipDesc = document.getElementById("UITourTooltipDescription");
1074 let tooltipIcon = document.getElementById("UITourTooltipIcon");
1075 let tooltipButtons = document.getElementById("UITourTooltipButtons");
1077 if (tooltip.state == "showing" || tooltip.state == "open") {
1078 tooltip.hidePopup();
1081 tooltipTitle.textContent = aTitle || "";
1082 tooltipDesc.textContent = aDescription || "";
1083 tooltipIcon.src = aIconURL || "";
1084 tooltipIcon.hidden = !aIconURL;
1086 while (tooltipButtons.firstChild)
1087 tooltipButtons.firstChild.remove();
1089 for (let button of aButtons) {
1090 let el = document.createElement("button");
1091 el.setAttribute("label", button.label);
1093 el.setAttribute("image", button.iconURL);
1095 if (button.style == "link")
1096 el.setAttribute("class", "button-link");
1098 if (button.style == "primary")
1099 el.setAttribute("class", "button-primary");
1101 let callbackID = button.callbackID;
1102 el.addEventListener("command", event => {
1103 tooltip.hidePopup();
1104 this.sendPageCallback(aContentDocument, callbackID);
1107 tooltipButtons.appendChild(el);
1110 tooltipButtons.hidden = !aButtons.length;
1112 let tooltipClose = document.getElementById("UITourTooltipClose");
1113 let closeButtonCallback = (event) => {
1114 this.hideInfo(document.defaultView);
1115 if (aOptions && aOptions.closeButtonCallbackID)
1116 this.sendPageCallback(aContentDocument, aOptions.closeButtonCallbackID);
1118 tooltipClose.addEventListener("command", closeButtonCallback);
1120 let targetCallback = (event) => {
1122 target: aAnchor.targetName,
1125 this.sendPageCallback(aContentDocument, aOptions.targetCallbackID, details);
1127 if (aOptions.targetCallbackID && aAnchor.addTargetListener) {
1128 aAnchor.addTargetListener(document, targetCallback);
1131 tooltip.addEventListener("popuphiding", function tooltipHiding(event) {
1132 tooltip.removeEventListener("popuphiding", tooltipHiding);
1133 tooltipClose.removeEventListener("command", closeButtonCallback);
1134 if (aOptions.targetCallbackID && aAnchor.removeTargetListener) {
1135 aAnchor.removeTargetListener(document, targetCallback);
1139 tooltip.setAttribute("targetName", aAnchor.targetName);
1140 tooltip.hidden = false;
1141 let xOffset = 0, yOffset = 0;
1142 let alignment = "bottomcenter topright";
1143 if (aAnchor.targetName == "search") {
1144 alignment = "after_start";
1147 this._addAnnotationPanelMutationObserver(tooltip);
1148 tooltip.openPopup(aAnchorEl, alignment, xOffset, yOffset);
1151 // Prevent showing a panel at an undefined position.
1152 if (!this.isElementVisible(aAnchor.node))
1155 // Due to a platform limitation, we can't anchor a panel to an element in a
1156 // <menupopup>. So we can't support showing info panels for search engines.
1157 if (aAnchor.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX))
1160 this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info",
1161 this.targetIsInAppMenu(aAnchor),
1162 showInfoPanel.bind(this, aAnchor.node));
1165 hideInfo: function(aWindow) {
1166 let document = aWindow.document;
1168 let tooltip = document.getElementById("UITourTooltip");
1169 this._removeAnnotationPanelMutationObserver(tooltip);
1170 tooltip.hidePopup();
1171 this._setAppMenuStateForAnnotation(aWindow, "info", false);
1173 let tooltipButtons = document.getElementById("UITourTooltipButtons");
1174 while (tooltipButtons.firstChild)
1175 tooltipButtons.firstChild.remove();
1178 showMenu: function(aWindow, aMenuName, aOpenCallback = null) {
1179 function openMenuButton(aMenuBtn) {
1180 if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) {
1186 aMenuBtn.addEventListener("popupshown", onPopupShown);
1187 aMenuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true);
1189 function onPopupShown(event) {
1190 this.removeEventListener("popupshown", onPopupShown);
1191 aOpenCallback(event);
1194 if (aMenuName == "appMenu") {
1195 aWindow.PanelUI.panel.setAttribute("noautohide", "true");
1196 // If the popup is already opened, don't recreate the widget as it may cause a flicker.
1197 if (aWindow.PanelUI.panel.state != "open") {
1198 this.recreatePopup(aWindow.PanelUI.panel);
1200 aWindow.PanelUI.panel.addEventListener("popuphiding", this.hidePanelAnnotations);
1201 aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hidePanelAnnotations);
1202 if (aOpenCallback) {
1203 aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown);
1205 aWindow.PanelUI.show();
1206 } else if (aMenuName == "bookmarks") {
1207 let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1208 openMenuButton(menuBtn);
1209 } else if (aMenuName == "searchEngines") {
1210 this.getTarget(aWindow, "searchProvider").then(target => {
1211 openMenuButton(target.node);
1212 }).catch(Cu.reportError);
1216 hideMenu: function(aWindow, aMenuName) {
1217 function closeMenuButton(aMenuBtn) {
1218 if (aMenuBtn && aMenuBtn.boxObject)
1219 aMenuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(false);
1222 if (aMenuName == "appMenu") {
1223 aWindow.PanelUI.panel.removeAttribute("noautohide");
1224 aWindow.PanelUI.hide();
1225 this.recreatePopup(aWindow.PanelUI.panel);
1226 } else if (aMenuName == "bookmarks") {
1227 let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1228 closeMenuButton(menuBtn);
1229 } else if (aMenuName == "searchEngines") {
1230 let menuBtn = this.targets.get("searchProvider").query(aWindow.document);
1231 closeMenuButton(menuBtn);
1235 hidePanelAnnotations: function(aEvent) {
1236 let win = aEvent.target.ownerDocument.defaultView;
1237 let annotationElements = new Map([
1238 // [annotationElement (panel), method to hide the annotation]
1239 [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)],
1240 [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)],
1242 annotationElements.forEach((hideMethod, annotationElement) => {
1243 if (annotationElement.state != "closed") {
1244 let targetName = annotationElement.getAttribute("targetName");
1245 UITour.getTarget(win, targetName).then((aTarget) => {
1246 // Since getTarget is async, we need to make sure that the target hasn't
1247 // changed since it may have just moved to somewhere outside of the app menu.
1248 if (annotationElement.getAttribute("targetName") != aTarget.targetName ||
1249 annotationElement.state == "closed" ||
1250 !UITour.targetIsInAppMenu(aTarget)) {
1254 }).then(null, Cu.reportError);
1257 UITour.appMenuOpenForAnnotation.clear();
1260 recreatePopup: function(aPanel) {
1261 // After changing popup attributes that relate to how the native widget is created
1262 // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect.
1263 if (aPanel.hidden) {
1264 // If the panel is already hidden, we don't need to recreate it but flush
1265 // in case someone just hid it.
1266 aPanel.clientWidth; // flush
1269 aPanel.hidden = true;
1270 aPanel.clientWidth; // flush
1271 aPanel.hidden = false;
1274 startUrlbarCapture: function(aWindow, aExpectedText, aUrl) {
1275 let urlbar = aWindow.document.getElementById("urlbar");
1276 this.urlbarCapture.set(aWindow, {
1277 expected: aExpectedText.toLocaleLowerCase(),
1280 urlbar.addEventListener("input", this);
1283 endUrlbarCapture: function(aWindow) {
1284 let urlbar = aWindow.document.getElementById("urlbar");
1285 urlbar.removeEventListener("input", this);
1286 this.urlbarCapture.delete(aWindow);
1289 handleUrlbarInput: function(aWindow) {
1290 if (!this.urlbarCapture.has(aWindow))
1293 let urlbar = aWindow.document.getElementById("urlbar");
1295 let {expected, url} = this.urlbarCapture.get(aWindow);
1297 if (urlbar.value.toLocaleLowerCase().localeCompare(expected) != 0)
1300 urlbar.handleRevert();
1302 let tab = aWindow.gBrowser.addTab(url, {
1303 owner: aWindow.gBrowser.selectedTab,
1304 relatedToCurrent: true
1306 aWindow.gBrowser.selectedTab = tab;
1309 getConfiguration: function(aContentDocument, aConfiguration, aCallbackID) {
1310 switch (aConfiguration) {
1311 case "availableTargets":
1312 this.getAvailableTargets(aContentDocument, aCallbackID);
1315 this.sendPageCallback(aContentDocument, aCallbackID, {
1316 setup: Services.prefs.prefHasUserValue("services.sync.username"),
1319 case "selectedSearchEngine":
1320 Services.search.init(rv => {
1322 if (Components.isSuccessCode(rv)) {
1323 engine = Services.search.defaultEngine;
1325 engine = { identifier: "" };
1327 this.sendPageCallback(aContentDocument, aCallbackID, {
1328 searchEngineIdentifier: engine.identifier
1333 Cu.reportError("getConfiguration: Unknown configuration requested: " + aConfiguration);
1338 getAvailableTargets: function(aContentDocument, aCallbackID) {
1339 Task.spawn(function*() {
1340 let window = this.getChromeWindow(aContentDocument);
1341 let data = this.availableTargetsCache.get(window);
1343 this.sendPageCallback(aContentDocument, aCallbackID, data);
1348 for (let targetName of this.targets.keys()) {
1349 promises.push(this.getTarget(window, targetName));
1351 let targetObjects = yield Promise.all(promises);
1357 for (let targetObject of targetObjects) {
1358 if (targetObject.node)
1359 targetNames.push(targetObject.targetName);
1362 targetNames = targetNames.concat(
1363 yield this.getAvailableSearchEngineTargets(window)
1367 targets: targetNames,
1369 this.availableTargetsCache.set(window, data);
1370 this.sendPageCallback(aContentDocument, aCallbackID, data);
1371 }.bind(this)).catch(err => {
1372 Cu.reportError(err);
1373 this.sendPageCallback(aContentDocument, aCallbackID, {
1379 addNavBarWidget: function (aTarget, aContentDocument, aCallbackID) {
1381 Cu.reportError("UITour: can't add a widget already present: " + data.target);
1384 if (!aTarget.allowAdd) {
1385 Cu.reportError("UITour: not allowed to add this widget: " + data.target);
1388 if (!aTarget.widgetName) {
1389 Cu.reportError("UITour: can't add a widget without a widgetName property: " + data.target);
1393 CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
1394 this.sendPageCallback(aContentDocument, aCallbackID);
1397 _addAnnotationPanelMutationObserver: function(aPanelEl) {
1399 let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1403 let win = aPanelEl.ownerDocument.defaultView;
1404 observer = new win.MutationObserver(this._annotationMutationCallback);
1405 this._annotationPanelMutationObservers.set(aPanelEl, observer);
1406 let observerOptions = {
1407 attributeFilter: ["height", "width"],
1410 observer.observe(aPanelEl, observerOptions);
1414 _removeAnnotationPanelMutationObserver: function(aPanelEl) {
1416 let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1418 observer.disconnect();
1419 this._annotationPanelMutationObservers.delete(aPanelEl);
1425 * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
1426 * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
1429 _annotationMutationCallback: function(aMutations) {
1430 for (let mutation of aMutations) {
1431 // Remove both attributes at once and ignore remaining mutations to be proccessed.
1432 mutation.target.removeAttribute("width");
1433 mutation.target.removeAttribute("height");
1438 selectSearchEngine(aID) {
1439 return new Promise((resolve, reject) => {
1440 Services.search.init((rv) => {
1441 if (!Components.isSuccessCode(rv)) {
1442 reject("selectSearchEngine: search service init failed: " + rv);
1446 let engines = Services.search.getVisibleEngines();
1447 for (let engine of engines) {
1448 if (engine.identifier == aID) {
1449 Services.search.defaultEngine = engine;
1453 reject("selectSearchEngine could not find engine with given ID");
1458 getAvailableSearchEngineTargets(aWindow) {
1459 return new Promise(resolve => {
1460 this.getTarget(aWindow, "search").then(searchTarget => {
1461 if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
1464 Services.search.init(() => {
1465 let engines = Services.search.getVisibleEngines();
1466 resolve([TARGET_SEARCHENGINE_PREFIX + engine.identifier
1467 for (engine of engines)
1468 if (engine.identifier)]);
1470 }).catch(() => resolve([]));
1474 // We only allow matching based on a search engine's identifier - this gives
1475 // us a non-changing ID and guarentees we only match against app-provided
1477 getSearchEngineTarget(aWindow, aIdentifier) {
1478 return new Promise((resolve, reject) => {
1479 Task.spawn(function*() {
1480 let searchTarget = yield this.getTarget(aWindow, "search");
1481 // We're not supporting having the searchbar in the app-menu, because
1482 // popups within popups gets crazy. This restriction should be lifted
1483 // once bug 988151 is implemented, as the page can then be responsible
1484 // for opening each menu when appropriate.
1485 if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
1486 return reject("Search engine not available");
1488 yield Services.search.init();
1490 let searchPopup = searchTarget.node._popup;
1491 for (let engineNode of searchPopup.children) {
1492 let engine = engineNode.engine;
1493 if (engine && engine.identifier == aIdentifier) {
1495 targetName: TARGET_SEARCHENGINE_PREFIX + engine.identifier,
1500 reject("Search engine not available");
1501 }.bind(this)).catch(() => {
1502 reject("Search engine not available");
1511 * UITour Health Report
1513 const DAILY_DISCRETE_TEXT_FIELD = Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT;
1516 * Public API to be called by the UITour code
1518 const UITourHealthReport = {
1519 recordTreatmentTag: function(tag, value) {
1520 #ifdef MOZ_SERVICES_HEALTHREPORT
1521 Task.spawn(function*() {
1522 let reporter = Cc["@mozilla.org/datareporting/service;1"]
1527 // This can happen if the FHR component of the data reporting service is
1528 // disabled. This is controlled by a pref that most will never use.
1533 yield reporter.onInit();
1535 // Get the UITourMetricsProvider instance from the Health Reporter
1536 reporter.getProvider("org.mozilla.uitour").recordTreatmentTag(tag, value);
1542 this.UITourMetricsProvider = function() {
1543 Metrics.Provider.call(this);
1546 UITourMetricsProvider.prototype = Object.freeze({
1547 __proto__: Metrics.Provider.prototype,
1549 name: "org.mozilla.uitour",
1552 UITourTreatmentMeasurement1,
1555 recordTreatmentTag: function(tag, value) {
1556 let m = this.getMeasurement(UITourTreatmentMeasurement1.prototype.name,
1557 UITourTreatmentMeasurement1.prototype.version);
1560 if (this.storage.hasFieldFromMeasurement(m.id, field,
1561 DAILY_DISCRETE_TEXT_FIELD)) {
1562 let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
1563 return this.enqueueStorageOperation(function recordKnownField() {
1564 return this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
1568 // Otherwise, we first need to create the field.
1569 return this.enqueueStorageOperation(function recordField() {
1570 // This function has to return a promise.
1571 return Task.spawn(function () {
1572 let fieldID = yield this.storage.registerField(m.id, field,
1573 DAILY_DISCRETE_TEXT_FIELD);
1574 yield this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
1580 function UITourTreatmentMeasurement1() {
1581 Metrics.Measurement.call(this);
1583 this._serializers = {};
1584 this._serializers[this.SERIALIZE_JSON] = {
1585 //singular: We don't need a singular serializer because we have none of this data
1586 daily: this._serializeJSONDaily.bind(this)
1591 UITourTreatmentMeasurement1.prototype = Object.freeze({
1592 __proto__: Metrics.Measurement.prototype,
1597 // our fields are dynamic
1600 // We need a custom serializer because the default one doesn't accept unknown fields
1601 _serializeJSONDaily: function(data) {
1602 let result = {_v: this.version };
1604 for (let [field, data] of data) {
1605 result[field] = data;