browser/modules/UITour.jsm

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     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/.
     5 "use strict";
     7 this.EXPORTED_SYMBOLS = ["UITour"];
     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");
    15 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
    16   "resource://gre/modules/LightweightThemeManager.jsm");
    17 XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
    18   "resource://gre/modules/PermissionsUtils.jsm");
    19 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
    20   "resource:///modules/CustomizableUI.jsm");
    21 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
    22   "resource://gre/modules/UITelemetry.jsm");
    23 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
    24   "resource:///modules/BrowserUITelemetry.jsm");
    27 const UITOUR_PERMISSION   = "uitour";
    28 const PREF_PERM_BRANCH    = "browser.uitour.";
    29 const PREF_SEENPAGEIDS    = "browser.uitour.seenPageIDs";
    30 const MAX_BUTTONS         = 4;
    32 const BUCKET_NAME         = "UITour";
    33 const BUCKET_TIMESTEPS    = [
    34   1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
    35   3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
    36   10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
    37   60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
    38 ];
    40 // Time after which seen Page IDs expire.
    41 const SEENPAGEID_EXPIRY  = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.
    44 this.UITour = {
    45   url: null,
    46   seenPageIDs: null,
    47   pageIDSourceTabs: new WeakMap(),
    48   pageIDSourceWindows: new WeakMap(),
    49   /* Map from browser windows to a set of tabs in which a tour is open */
    50   originTabs: new WeakMap(),
    51   /* Map from browser windows to a set of pinned tabs opened by (a) tour(s) */
    52   pinnedTabs: new WeakMap(),
    53   urlbarCapture: new WeakMap(),
    54   appMenuOpenForAnnotation: new Set(),
    55   availableTargetsCache: new WeakMap(),
    57   _detachingTab: false,
    58   _annotationPanelMutationObservers: new WeakMap(),
    59   _queuedEvents: [],
    60   _pendingDoc: null,
    62   highlightEffects: ["random", "wobble", "zoom", "color"],
    63   targets: new Map([
    64     ["accountStatus", {
    65       query: (aDocument) => {
    66         let statusButton = aDocument.getElementById("PanelUI-fxa-status");
    67         return aDocument.getAnonymousElementByAttribute(statusButton,
    68                                                         "class",
    69                                                         "toolbarbutton-icon");
    70       },
    71       widgetName: "PanelUI-fxa-status",
    72     }],
    73     ["addons",      {query: "#add-ons-button"}],
    74     ["appMenu",     {
    75       addTargetListener: (aDocument, aCallback) => {
    76         let panelPopup = aDocument.getElementById("PanelUI-popup");
    77         panelPopup.addEventListener("popupshown", aCallback);
    78       },
    79       query: "#PanelUI-button",
    80       removeTargetListener: (aDocument, aCallback) => {
    81         let panelPopup = aDocument.getElementById("PanelUI-popup");
    82         panelPopup.removeEventListener("popupshown", aCallback);
    83       },
    84     }],
    85     ["backForward", {
    86       query: "#back-button",
    87       widgetName: "urlbar-container",
    88     }],
    89     ["bookmarks",   {query: "#bookmarks-menu-button"}],
    90     ["customize",   {
    91       query: (aDocument) => {
    92         let customizeButton = aDocument.getElementById("PanelUI-customize");
    93         return aDocument.getAnonymousElementByAttribute(customizeButton,
    94                                                         "class",
    95                                                         "toolbarbutton-icon");
    96       },
    97       widgetName: "PanelUI-customize",
    98     }],
    99     ["help",        {query: "#PanelUI-help"}],
   100     ["home",        {query: "#home-button"}],
   101     ["quit",        {query: "#PanelUI-quit"}],
   102     ["search",      {
   103       query: "#searchbar",
   104       widgetName: "search-container",
   105     }],
   106     ["searchProvider", {
   107       query: (aDocument) => {
   108         let searchbar = aDocument.getElementById("searchbar");
   109         return aDocument.getAnonymousElementByAttribute(searchbar,
   110                                                         "anonid",
   111                                                         "searchbar-engine-button");
   112       },
   113       widgetName: "search-container",
   114     }],
   115     ["selectedTabIcon", {
   116       query: (aDocument) => {
   117         let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
   118         let element = aDocument.getAnonymousElementByAttribute(selectedtab,
   119                                                                "anonid",
   120                                                                "tab-icon-image");
   121         if (!element || !UITour.isElementVisible(element)) {
   122           return null;
   123         }
   124         return element;
   125       },
   126     }],
   127     ["urlbar",      {
   128       query: "#urlbar",
   129       widgetName: "urlbar-container",
   130     }],
   131   ]),
   133   init: function() {
   134     // Lazy getter is initialized here so it can be replicated any time
   135     // in a test.
   136     delete this.seenPageIDs;
   137     Object.defineProperty(this, "seenPageIDs", {
   138       get: this.restoreSeenPageIDs.bind(this),
   139       configurable: true,
   140     });
   142     delete this.url;
   143     XPCOMUtils.defineLazyGetter(this, "url", function () {
   144       return Services.urlFormatter.formatURLPref("browser.uitour.url");
   145     });
   147     // Clear the availableTargetsCache on widget changes.
   148     let listenerMethods = [
   149       "onWidgetAdded",
   150       "onWidgetMoved",
   151       "onWidgetRemoved",
   152       "onWidgetReset",
   153       "onAreaReset",
   154     ];
   155     CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
   156       listener[method] = () => this.availableTargetsCache.clear();
   157       return listener;
   158     }, {}));
   159   },
   161   restoreSeenPageIDs: function() {
   162     delete this.seenPageIDs;
   164     if (UITelemetry.enabled) {
   165       let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;
   167       try {
   168         let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
   169         data = new Map(JSON.parse(data));
   171         for (let [pageID, details] of data) {
   173           if (typeof pageID != "string" ||
   174               typeof details != "object" ||
   175               typeof details.lastSeen != "number" ||
   176               details.lastSeen < dateThreshold) {
   178             data.delete(pageID);
   179           }
   180         }
   182         this.seenPageIDs = data;
   183       } catch (e) {}
   184     }
   186     if (!this.seenPageIDs)
   187       this.seenPageIDs = new Map();
   189     this.persistSeenIDs();
   191     return this.seenPageIDs;
   192   },
   194   addSeenPageID: function(aPageID) {
   195     if (!UITelemetry.enabled)
   196       return;
   198     this.seenPageIDs.set(aPageID, {
   199       lastSeen: Date.now(),
   200     });
   202     this.persistSeenIDs();
   203   },
   205   persistSeenIDs: function() {
   206     if (this.seenPageIDs.size === 0) {
   207       Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
   208       return;
   209     }
   211     Services.prefs.setCharPref(PREF_SEENPAGEIDS,
   212                                JSON.stringify([...this.seenPageIDs]));
   213   },
   215   onPageEvent: function(aEvent) {
   216     let contentDocument = null;
   217     if (aEvent.target instanceof Ci.nsIDOMHTMLDocument)
   218       contentDocument = aEvent.target;
   219     else if (aEvent.target instanceof Ci.nsIDOMHTMLElement)
   220       contentDocument = aEvent.target.ownerDocument;
   221     else
   222       return false;
   224     // Ignore events if they're not from a trusted origin.
   225     if (!this.ensureTrustedOrigin(contentDocument))
   226       return false;
   228     if (typeof aEvent.detail != "object")
   229       return false;
   231     let action = aEvent.detail.action;
   232     if (typeof action != "string" || !action)
   233       return false;
   235     let data = aEvent.detail.data;
   236     if (typeof data != "object")
   237       return false;
   239     let window = this.getChromeWindow(contentDocument);
   240     // Do this before bailing if there's no tab, so later we can pick up the pieces:
   241     window.gBrowser.tabContainer.addEventListener("TabSelect", this);
   242     let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
   243     if (!tab) {
   244       // This should only happen while detaching a tab:
   245       if (this._detachingTab) {
   246         this._queuedEvents.push(aEvent);
   247         this._pendingDoc = Cu.getWeakReference(contentDocument);
   248         return;
   249       }
   250       Cu.reportError("Discarding tabless UITour event (" + action + ") while not detaching a tab." +
   251                      "This shouldn't happen!");
   252       return;
   253     }
   255     switch (action) {
   256       case "registerPageID": {
   257         // This is only relevant if Telemtry is enabled.
   258         if (!UITelemetry.enabled)
   259           break;
   261         // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
   262         // pageID, as it could make parsing the telemetry bucket name difficult.
   263         if (typeof data.pageID == "string" &&
   264             !data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
   265           this.addSeenPageID(data.pageID);
   267           // Store tabs and windows separately so we don't need to loop over all
   268           // tabs when a window is closed.
   269           this.pageIDSourceTabs.set(tab, data.pageID);
   270           this.pageIDSourceWindows.set(window, data.pageID);
   272           this.setTelemetryBucket(data.pageID);
   273         }
   274         break;
   275       }
   277       case "showHighlight": {
   278         let targetPromise = this.getTarget(window, data.target);
   279         targetPromise.then(target => {
   280           if (!target.node) {
   281             Cu.reportError("UITour: Target could not be resolved: " + data.target);
   282             return;
   283           }
   284           let effect = undefined;
   285           if (this.highlightEffects.indexOf(data.effect) !== -1) {
   286             effect = data.effect;
   287           }
   288           this.showHighlight(target, effect);
   289         }).then(null, Cu.reportError);
   290         break;
   291       }
   293       case "hideHighlight": {
   294         this.hideHighlight(window);
   295         break;
   296       }
   298       case "showInfo": {
   299         let targetPromise = this.getTarget(window, data.target, true);
   300         targetPromise.then(target => {
   301           if (!target.node) {
   302             Cu.reportError("UITour: Target could not be resolved: " + data.target);
   303             return;
   304           }
   306           let iconURL = null;
   307           if (typeof data.icon == "string")
   308             iconURL = this.resolveURL(contentDocument, data.icon);
   310           let buttons = [];
   311           if (Array.isArray(data.buttons) && data.buttons.length > 0) {
   312             for (let buttonData of data.buttons) {
   313               if (typeof buttonData == "object" &&
   314                   typeof buttonData.label == "string" &&
   315                   typeof buttonData.callbackID == "string") {
   316                 let button = {
   317                   label: buttonData.label,
   318                   callbackID: buttonData.callbackID,
   319                 };
   321                 if (typeof buttonData.icon == "string")
   322                   button.iconURL = this.resolveURL(contentDocument, buttonData.icon);
   324                 if (typeof buttonData.style == "string")
   325                   button.style = buttonData.style;
   327                 buttons.push(button);
   329                 if (buttons.length == MAX_BUTTONS)
   330                   break;
   331               }
   332             }
   333           }
   335           let infoOptions = {};
   337           if (typeof data.closeButtonCallbackID == "string")
   338             infoOptions.closeButtonCallbackID = data.closeButtonCallbackID;
   339           if (typeof data.targetCallbackID == "string")
   340             infoOptions.targetCallbackID = data.targetCallbackID;
   342           this.showInfo(contentDocument, target, data.title, data.text, iconURL, buttons, infoOptions);
   343         }).then(null, Cu.reportError);
   344         break;
   345       }
   347       case "hideInfo": {
   348         this.hideInfo(window);
   349         break;
   350       }
   352       case "previewTheme": {
   353         this.previewTheme(data.theme);
   354         break;
   355       }
   357       case "resetTheme": {
   358         this.resetTheme();
   359         break;
   360       }
   362       case "addPinnedTab": {
   363         this.ensurePinnedTab(window, true);
   364         break;
   365       }
   367       case "removePinnedTab": {
   368         this.removePinnedTab(window);
   369         break;
   370       }
   372       case "showMenu": {
   373         this.showMenu(window, data.name);
   374         break;
   375       }
   377       case "hideMenu": {
   378         this.hideMenu(window, data.name);
   379         break;
   380       }
   382       case "startUrlbarCapture": {
   383         if (typeof data.text != "string" || !data.text ||
   384             typeof data.url != "string" || !data.url) {
   385           return false;
   386         }
   388         let uri = null;
   389         try {
   390           uri = Services.io.newURI(data.url, null, null);
   391         } catch (e) {
   392           return false;
   393         }
   395         let secman = Services.scriptSecurityManager;
   396         let principal = contentDocument.nodePrincipal;
   397         let flags = secman.DISALLOW_INHERIT_PRINCIPAL;
   398         try {
   399           secman.checkLoadURIWithPrincipal(principal, uri, flags);
   400         } catch (e) {
   401           return false;
   402         }
   404         this.startUrlbarCapture(window, data.text, data.url);
   405         break;
   406       }
   408       case "endUrlbarCapture": {
   409         this.endUrlbarCapture(window);
   410         break;
   411       }
   413       case "getConfiguration": {
   414         if (typeof data.configuration != "string") {
   415           return false;
   416         }
   418         this.getConfiguration(contentDocument, data.configuration, data.callbackID);
   419         break;
   420       }
   422       case "showFirefoxAccounts": {
   423         // 'signup' is the only action that makes sense currently, so we don't
   424         // accept arbitrary actions just to be safe...
   425         // We want to replace the current tab.
   426         contentDocument.location.href = "about:accounts?action=signup";
   427         break;
   428       }
   429     }
   431     if (!this.originTabs.has(window))
   432       this.originTabs.set(window, new Set());
   434     this.originTabs.get(window).add(tab);
   435     tab.addEventListener("TabClose", this);
   436     tab.addEventListener("TabBecomingWindow", this);
   437     window.addEventListener("SSWindowClosing", this);
   439     return true;
   440   },
   442   handleEvent: function(aEvent) {
   443     switch (aEvent.type) {
   444       case "pagehide": {
   445         let window = this.getChromeWindow(aEvent.target);
   446         this.teardownTour(window);
   447         break;
   448       }
   450       case "TabBecomingWindow":
   451         this._detachingTab = true;
   452         // Fall through
   453       case "TabClose": {
   454         let tab = aEvent.target;
   455         if (this.pageIDSourceTabs.has(tab)) {
   456           let pageID = this.pageIDSourceTabs.get(tab);
   458           // Delete this from the window cache, so if the window is closed we
   459           // don't expire this page ID twice.
   460           let window = tab.ownerDocument.defaultView;
   461           if (this.pageIDSourceWindows.get(window) == pageID)
   462             this.pageIDSourceWindows.delete(window);
   464           this.setExpiringTelemetryBucket(pageID, "closed");
   465         }
   467         let window = tab.ownerDocument.defaultView;
   468         this.teardownTour(window);
   469         break;
   470       }
   472       case "TabSelect": {
   473         if (aEvent.detail && aEvent.detail.previousTab) {
   474           let previousTab = aEvent.detail.previousTab;
   476           if (this.pageIDSourceTabs.has(previousTab)) {
   477             let pageID = this.pageIDSourceTabs.get(previousTab);
   478             this.setExpiringTelemetryBucket(pageID, "inactive");
   479           }
   480         }
   482         let window = aEvent.target.ownerDocument.defaultView;
   483         let selectedTab = window.gBrowser.selectedTab;
   484         let pinnedTab = this.pinnedTabs.get(window);
   485         if (pinnedTab && pinnedTab.tab == selectedTab)
   486           break;
   487         let originTabs = this.originTabs.get(window);
   488         if (originTabs && originTabs.has(selectedTab))
   489           break;
   491         let pendingDoc;
   492         if (this._detachingTab && this._pendingDoc && (pendingDoc = this._pendingDoc.get())) {
   493           if (selectedTab.linkedBrowser.contentDocument == pendingDoc) {
   494             if (!this.originTabs.get(window)) {
   495               this.originTabs.set(window, new Set());
   496             }
   497             this.originTabs.get(window).add(selectedTab);
   498             this.pendingDoc = null;
   499             this._detachingTab = false;
   500             while (this._queuedEvents.length) {
   501               try {
   502                 this.onPageEvent(this._queuedEvents.shift());
   503               } catch (ex) {
   504                 Cu.reportError(ex);
   505               }
   506             }
   507             break;
   508           }
   509         }
   511         this.teardownTour(window);
   512         break;
   513       }
   515       case "SSWindowClosing": {
   516         let window = aEvent.target;
   517         if (this.pageIDSourceWindows.has(window)) {
   518           let pageID = this.pageIDSourceWindows.get(window);
   519           this.setExpiringTelemetryBucket(pageID, "closed");
   520         }
   522         this.teardownTour(window, true);
   523         break;
   524       }
   526       case "input": {
   527         if (aEvent.target.id == "urlbar") {
   528           let window = aEvent.target.ownerDocument.defaultView;
   529           this.handleUrlbarInput(window);
   530         }
   531         break;
   532       }
   533     }
   534   },
   536   setTelemetryBucket: function(aPageID) {
   537     let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
   538     BrowserUITelemetry.setBucket(bucket);
   539   },
   541   setExpiringTelemetryBucket: function(aPageID, aType) {
   542     let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
   543                  BrowserUITelemetry.BUCKET_SEPARATOR + aType;
   545     BrowserUITelemetry.setExpiringBucket(bucket,
   546                                          BUCKET_TIMESTEPS);
   547   },
   549   // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
   550   // can remain lazy-loaded on-demand.
   551   getTelemetry: function() {
   552     return {
   553       seenPageIDs: [...this.seenPageIDs.keys()],
   554     };
   555   },
   557   teardownTour: function(aWindow, aWindowClosing = false) {
   558     aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
   559     aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hidePanelAnnotations);
   560     aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hidePanelAnnotations);
   561     aWindow.removeEventListener("SSWindowClosing", this);
   563     let originTabs = this.originTabs.get(aWindow);
   564     if (originTabs) {
   565       for (let tab of originTabs) {
   566         tab.removeEventListener("TabClose", this);
   567         tab.removeEventListener("TabBecomingWindow", this);
   568       }
   569     }
   570     this.originTabs.delete(aWindow);
   572     if (!aWindowClosing) {
   573       this.hideHighlight(aWindow);
   574       this.hideInfo(aWindow);
   575       // Ensure the menu panel is hidden before calling recreatePopup so popup events occur.
   576       this.hideMenu(aWindow, "appMenu");
   577     }
   579     this.endUrlbarCapture(aWindow);
   580     this.removePinnedTab(aWindow);
   581     this.resetTheme();
   582   },
   584   getChromeWindow: function(aContentDocument) {
   585     return aContentDocument.defaultView
   586                            .window
   587                            .QueryInterface(Ci.nsIInterfaceRequestor)
   588                            .getInterface(Ci.nsIWebNavigation)
   589                            .QueryInterface(Ci.nsIDocShellTreeItem)
   590                            .rootTreeItem
   591                            .QueryInterface(Ci.nsIInterfaceRequestor)
   592                            .getInterface(Ci.nsIDOMWindow)
   593                            .wrappedJSObject;
   594   },
   596   importPermissions: function() {
   597     try {
   598       PermissionsUtils.importFromPrefs(PREF_PERM_BRANCH, UITOUR_PERMISSION);
   599     } catch (e) {
   600       Cu.reportError(e);
   601     }
   602   },
   604   ensureTrustedOrigin: function(aDocument) {
   605     if (aDocument.defaultView.top != aDocument.defaultView)
   606       return false;
   608     let uri = aDocument.documentURIObject;
   610     if (uri.schemeIs("chrome"))
   611       return true;
   613     if (!this.isSafeScheme(uri))
   614       return false;
   616     this.importPermissions();
   617     let permission = Services.perms.testPermission(uri, UITOUR_PERMISSION);
   618     return permission == Services.perms.ALLOW_ACTION;
   619   },
   621   isSafeScheme: function(aURI) {
   622     let allowedSchemes = new Set(["https"]);
   623     if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
   624       allowedSchemes.add("http");
   626     if (!allowedSchemes.has(aURI.scheme))
   627       return false;
   629     return true;
   630   },
   632   resolveURL: function(aDocument, aURL) {
   633     try {
   634       let uri = Services.io.newURI(aURL, null, aDocument.documentURIObject);
   636       if (!this.isSafeScheme(uri))
   637         return null;
   639       return uri.spec;
   640     } catch (e) {}
   642     return null;
   643   },
   645   sendPageCallback: function(aDocument, aCallbackID, aData = {}) {
   646     let detail = Cu.createObjectIn(aDocument.defaultView);
   647     detail.data = Cu.createObjectIn(detail);
   649     for (let key of Object.keys(aData))
   650       detail.data[key] = aData[key];
   652     Cu.makeObjectPropsNormal(detail.data);
   653     Cu.makeObjectPropsNormal(detail);
   655     detail.callbackID = aCallbackID;
   657     let event = new aDocument.defaultView.CustomEvent("mozUITourResponse", {
   658       bubbles: true,
   659       detail: detail
   660     });
   662     aDocument.dispatchEvent(event);
   663   },
   665   isElementVisible: function(aElement) {
   666     let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
   667     return (targetStyle.display != "none" && targetStyle.visibility == "visible");
   668   },
   670   getTarget: function(aWindow, aTargetName, aSticky = false) {
   671     let deferred = Promise.defer();
   672     if (typeof aTargetName != "string" || !aTargetName) {
   673       deferred.reject("Invalid target name specified");
   674       return deferred.promise;
   675     }
   677     if (aTargetName == "pinnedTab") {
   678       deferred.resolve({
   679           targetName: aTargetName,
   680           node: this.ensurePinnedTab(aWindow, aSticky)
   681       });
   682       return deferred.promise;
   683     }
   685     let targetObject = this.targets.get(aTargetName);
   686     if (!targetObject) {
   687       deferred.reject("The specified target name is not in the allowed set");
   688       return deferred.promise;
   689     }
   691     let targetQuery = targetObject.query;
   692     aWindow.PanelUI.ensureReady().then(() => {
   693       let node;
   694       if (typeof targetQuery == "function") {
   695         try {
   696           node = targetQuery(aWindow.document);
   697         } catch (ex) {
   698           node = null;
   699         }
   700       } else {
   701         node = aWindow.document.querySelector(targetQuery);
   702       }
   704       deferred.resolve({
   705         addTargetListener: targetObject.addTargetListener,
   706         node: node,
   707         removeTargetListener: targetObject.removeTargetListener,
   708         targetName: aTargetName,
   709         widgetName: targetObject.widgetName,
   710       });
   711     }).then(null, Cu.reportError);
   712     return deferred.promise;
   713   },
   715   targetIsInAppMenu: function(aTarget) {
   716     let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
   717     if (placement && placement.area == CustomizableUI.AREA_PANEL) {
   718       return true;
   719     }
   721     let targetElement = aTarget.node;
   722     // Use the widget for filtering if it exists since the target may be the icon inside.
   723     if (aTarget.widgetName) {
   724       targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
   725     }
   727     // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets.
   728     return targetElement.id.startsWith("PanelUI-")
   729              && targetElement.id != "PanelUI-button";
   730   },
   732   /**
   733    * Called before opening or after closing a highlight or info panel to see if
   734    * we need to open or close the appMenu to see the annotation's anchor.
   735    */
   736   _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) {
   737     // If the panel is in the desired state, we're done.
   738     let panelIsOpen = aWindow.PanelUI.panel.state != "closed";
   739     if (aShouldOpenForHighlight == panelIsOpen) {
   740       if (aCallback)
   741         aCallback();
   742       return;
   743     }
   745     // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead).
   746     if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) {
   747       if (aCallback)
   748         aCallback();
   749       return;
   750     }
   752     if (aShouldOpenForHighlight) {
   753       this.appMenuOpenForAnnotation.add(aAnnotationType);
   754     } else {
   755       this.appMenuOpenForAnnotation.delete(aAnnotationType);
   756     }
   758     // Actually show or hide the menu
   759     if (this.appMenuOpenForAnnotation.size) {
   760       this.showMenu(aWindow, "appMenu", aCallback);
   761     } else {
   762       this.hideMenu(aWindow, "appMenu");
   763       if (aCallback)
   764         aCallback();
   765     }
   767   },
   769   previewTheme: function(aTheme) {
   770     let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
   771     let data = LightweightThemeManager.parseTheme(aTheme, origin);
   772     if (data)
   773       LightweightThemeManager.previewTheme(data);
   774   },
   776   resetTheme: function() {
   777     LightweightThemeManager.resetPreview();
   778   },
   780   ensurePinnedTab: function(aWindow, aSticky = false) {
   781     let tabInfo = this.pinnedTabs.get(aWindow);
   783     if (tabInfo) {
   784       tabInfo.sticky = tabInfo.sticky || aSticky;
   785     } else {
   786       let url = Services.urlFormatter.formatURLPref("browser.uitour.pinnedTabUrl");
   788       let tab = aWindow.gBrowser.addTab(url);
   789       aWindow.gBrowser.pinTab(tab);
   790       tab.addEventListener("TabClose", () => {
   791         this.pinnedTabs.delete(aWindow);
   792       });
   794       tabInfo = {
   795         tab: tab,
   796         sticky: aSticky
   797       };
   798       this.pinnedTabs.set(aWindow, tabInfo);
   799     }
   801     return tabInfo.tab;
   802   },
   804   removePinnedTab: function(aWindow) {
   805     let tabInfo = this.pinnedTabs.get(aWindow);
   806     if (tabInfo)
   807       aWindow.gBrowser.removeTab(tabInfo.tab);
   808   },
   810   /**
   811    * @param aTarget    The element to highlight.
   812    * @param aEffect    (optional) The effect to use from UITour.highlightEffects or "none".
   813    * @see UITour.highlightEffects
   814    */
   815   showHighlight: function(aTarget, aEffect = "none") {
   816     function showHighlightPanel(aTargetEl) {
   817       let highlighter = aTargetEl.ownerDocument.getElementById("UITourHighlight");
   819       let effect = aEffect;
   820       if (effect == "random") {
   821         // Exclude "random" from the randomly selected effects.
   822         let randomEffect = 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1));
   823         if (randomEffect == this.highlightEffects.length)
   824           randomEffect--; // On the order of 1 in 2^62 chance of this happening.
   825         effect = this.highlightEffects[randomEffect];
   826       }
   827       // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays.
   828       highlighter.setAttribute("active", "none");
   829       aTargetEl.ownerDocument.defaultView.getComputedStyle(highlighter).animationName;
   830       highlighter.setAttribute("active", effect);
   831       highlighter.parentElement.setAttribute("targetName", aTarget.targetName);
   832       highlighter.parentElement.hidden = false;
   834       let targetRect = aTargetEl.getBoundingClientRect();
   835       let highlightHeight = targetRect.height;
   836       let highlightWidth = targetRect.width;
   837       let minDimension = Math.min(highlightHeight, highlightWidth);
   838       let maxDimension = Math.max(highlightHeight, highlightWidth);
   840       // If the dimensions are within 200% of each other (to include the bookmarks button),
   841       // make the highlight a circle with the largest dimension as the diameter.
   842       if (maxDimension / minDimension <= 3.0) {
   843         highlightHeight = highlightWidth = maxDimension;
   844         highlighter.style.borderRadius = "100%";
   845       } else {
   846         highlighter.style.borderRadius = "";
   847       }
   849       highlighter.style.height = highlightHeight + "px";
   850       highlighter.style.width = highlightWidth + "px";
   852       // Close a previous highlight so we can relocate the panel.
   853       if (highlighter.parentElement.state == "open") {
   854         highlighter.parentElement.hidePopup();
   855       }
   856       /* The "overlap" position anchors from the top-left but we want to centre highlights at their
   857          minimum size. */
   858       let highlightWindow = aTargetEl.ownerDocument.defaultView;
   859       let containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement);
   860       let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop);
   861       let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
   862       let highlightStyle = highlightWindow.getComputedStyle(highlighter);
   863       let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
   864       let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
   865       let offsetX = paddingTopPx
   866                       - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
   867       let offsetY = paddingLeftPx
   868                       - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
   870       this._addAnnotationPanelMutationObserver(highlighter.parentElement);
   871       highlighter.parentElement.openPopup(aTargetEl, "overlap", offsetX, offsetY);
   872     }
   874     // Prevent showing a panel at an undefined position.
   875     if (!this.isElementVisible(aTarget.node))
   876       return;
   878     this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight",
   879                                        this.targetIsInAppMenu(aTarget),
   880                                        showHighlightPanel.bind(this, aTarget.node));
   881   },
   883   hideHighlight: function(aWindow) {
   884     let tabData = this.pinnedTabs.get(aWindow);
   885     if (tabData && !tabData.sticky)
   886       this.removePinnedTab(aWindow);
   888     let highlighter = aWindow.document.getElementById("UITourHighlight");
   889     this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
   890     highlighter.parentElement.hidePopup();
   891     highlighter.removeAttribute("active");
   893     this._setAppMenuStateForAnnotation(aWindow, "highlight", false);
   894   },
   896   /**
   897    * Show an info panel.
   898    *
   899    * @param {Document} aContentDocument
   900    * @param {Node}     aAnchor
   901    * @param {String}   [aTitle=""]
   902    * @param {String}   [aDescription=""]
   903    * @param {String}   [aIconURL=""]
   904    * @param {Object[]} [aButtons=[]]
   905    * @param {Object}   [aOptions={}]
   906    * @param {String}   [aOptions.closeButtonCallbackID]
   907    */
   908   showInfo: function(aContentDocument, aAnchor, aTitle = "", aDescription = "", aIconURL = "",
   909                      aButtons = [], aOptions = {}) {
   910     function showInfoPanel(aAnchorEl) {
   911       aAnchorEl.focus();
   913       let document = aAnchorEl.ownerDocument;
   914       let tooltip = document.getElementById("UITourTooltip");
   915       let tooltipTitle = document.getElementById("UITourTooltipTitle");
   916       let tooltipDesc = document.getElementById("UITourTooltipDescription");
   917       let tooltipIcon = document.getElementById("UITourTooltipIcon");
   918       let tooltipButtons = document.getElementById("UITourTooltipButtons");
   920       if (tooltip.state == "open") {
   921         tooltip.hidePopup();
   922       }
   924       tooltipTitle.textContent = aTitle || "";
   925       tooltipDesc.textContent = aDescription || "";
   926       tooltipIcon.src = aIconURL || "";
   927       tooltipIcon.hidden = !aIconURL;
   929       while (tooltipButtons.firstChild)
   930         tooltipButtons.firstChild.remove();
   932       for (let button of aButtons) {
   933         let el = document.createElement("button");
   934         el.setAttribute("label", button.label);
   935         if (button.iconURL)
   936           el.setAttribute("image", button.iconURL);
   938         if (button.style == "link")
   939           el.setAttribute("class", "button-link");
   941         if (button.style == "primary")
   942           el.setAttribute("class", "button-primary");
   944         let callbackID = button.callbackID;
   945         el.addEventListener("command", event => {
   946           tooltip.hidePopup();
   947           this.sendPageCallback(aContentDocument, callbackID);
   948         });
   950         tooltipButtons.appendChild(el);
   951       }
   953       tooltipButtons.hidden = !aButtons.length;
   955       let tooltipClose = document.getElementById("UITourTooltipClose");
   956       let closeButtonCallback = (event) => {
   957         this.hideInfo(document.defaultView);
   958         if (aOptions && aOptions.closeButtonCallbackID)
   959           this.sendPageCallback(aContentDocument, aOptions.closeButtonCallbackID);
   960       };
   961       tooltipClose.addEventListener("command", closeButtonCallback);
   963       let targetCallback = (event) => {
   964         let details = {
   965           target: aAnchor.targetName,
   966           type: event.type,
   967         };
   968         this.sendPageCallback(aContentDocument, aOptions.targetCallbackID, details);
   969       };
   970       if (aOptions.targetCallbackID && aAnchor.addTargetListener) {
   971         aAnchor.addTargetListener(document, targetCallback);
   972       }
   974       tooltip.addEventListener("popuphiding", function tooltipHiding(event) {
   975         tooltip.removeEventListener("popuphiding", tooltipHiding);
   976         tooltipClose.removeEventListener("command", closeButtonCallback);
   977         if (aOptions.targetCallbackID && aAnchor.removeTargetListener) {
   978           aAnchor.removeTargetListener(document, targetCallback);
   979         }
   980       });
   982       tooltip.setAttribute("targetName", aAnchor.targetName);
   983       tooltip.hidden = false;
   984       let alignment = "bottomcenter topright";
   985       this._addAnnotationPanelMutationObserver(tooltip);
   986       tooltip.openPopup(aAnchorEl, alignment);
   987     }
   989     // Prevent showing a panel at an undefined position.
   990     if (!this.isElementVisible(aAnchor.node))
   991       return;
   993     this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info",
   994                                        this.targetIsInAppMenu(aAnchor),
   995                                        showInfoPanel.bind(this, aAnchor.node));
   996   },
   998   hideInfo: function(aWindow) {
   999     let document = aWindow.document;
  1001     let tooltip = document.getElementById("UITourTooltip");
  1002     this._removeAnnotationPanelMutationObserver(tooltip);
  1003     tooltip.hidePopup();
  1004     this._setAppMenuStateForAnnotation(aWindow, "info", false);
  1006     let tooltipButtons = document.getElementById("UITourTooltipButtons");
  1007     while (tooltipButtons.firstChild)
  1008       tooltipButtons.firstChild.remove();
  1009   },
  1011   showMenu: function(aWindow, aMenuName, aOpenCallback = null) {
  1012     function openMenuButton(aID) {
  1013       let menuBtn = aWindow.document.getElementById(aID);
  1014       if (!menuBtn || !menuBtn.boxObject) {
  1015         aOpenCallback();
  1016         return;
  1018       if (aOpenCallback)
  1019         menuBtn.addEventListener("popupshown", onPopupShown);
  1020       menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true);
  1022     function onPopupShown(event) {
  1023       this.removeEventListener("popupshown", onPopupShown);
  1024       aOpenCallback(event);
  1027     if (aMenuName == "appMenu") {
  1028       aWindow.PanelUI.panel.setAttribute("noautohide", "true");
  1029       // If the popup is already opened, don't recreate the widget as it may cause a flicker.
  1030       if (aWindow.PanelUI.panel.state != "open") {
  1031         this.recreatePopup(aWindow.PanelUI.panel);
  1033       aWindow.PanelUI.panel.addEventListener("popuphiding", this.hidePanelAnnotations);
  1034       aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hidePanelAnnotations);
  1035       if (aOpenCallback) {
  1036         aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown);
  1038       aWindow.PanelUI.show();
  1039     } else if (aMenuName == "bookmarks") {
  1040       openMenuButton("bookmarks-menu-button");
  1042   },
  1044   hideMenu: function(aWindow, aMenuName) {
  1045     function closeMenuButton(aID) {
  1046       let menuBtn = aWindow.document.getElementById(aID);
  1047       if (menuBtn && menuBtn.boxObject)
  1048         menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(false);
  1051     if (aMenuName == "appMenu") {
  1052       aWindow.PanelUI.panel.removeAttribute("noautohide");
  1053       aWindow.PanelUI.hide();
  1054       this.recreatePopup(aWindow.PanelUI.panel);
  1055     } else if (aMenuName == "bookmarks") {
  1056       closeMenuButton("bookmarks-menu-button");
  1058   },
  1060   hidePanelAnnotations: function(aEvent) {
  1061     let win = aEvent.target.ownerDocument.defaultView;
  1062     let annotationElements = new Map([
  1063       // [annotationElement (panel), method to hide the annotation]
  1064       [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)],
  1065       [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)],
  1066     ]);
  1067     annotationElements.forEach((hideMethod, annotationElement) => {
  1068       if (annotationElement.state != "closed") {
  1069         let targetName = annotationElement.getAttribute("targetName");
  1070         UITour.getTarget(win, targetName).then((aTarget) => {
  1071           // Since getTarget is async, we need to make sure that the target hasn't
  1072           // changed since it may have just moved to somewhere outside of the app menu.
  1073           if (annotationElement.getAttribute("targetName") != aTarget.targetName ||
  1074               annotationElement.state == "closed" ||
  1075               !UITour.targetIsInAppMenu(aTarget)) {
  1076             return;
  1078           hideMethod(win);
  1079         }).then(null, Cu.reportError);
  1081     });
  1082     UITour.appMenuOpenForAnnotation.clear();
  1083   },
  1085   recreatePopup: function(aPanel) {
  1086     // After changing popup attributes that relate to how the native widget is created
  1087     // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect.
  1088     if (aPanel.hidden) {
  1089       // If the panel is already hidden, we don't need to recreate it but flush
  1090       // in case someone just hid it.
  1091       aPanel.clientWidth; // flush
  1092       return;
  1094     aPanel.hidden = true;
  1095     aPanel.clientWidth; // flush
  1096     aPanel.hidden = false;
  1097   },
  1099   startUrlbarCapture: function(aWindow, aExpectedText, aUrl) {
  1100     let urlbar = aWindow.document.getElementById("urlbar");
  1101     this.urlbarCapture.set(aWindow, {
  1102       expected: aExpectedText.toLocaleLowerCase(),
  1103       url: aUrl
  1104     });
  1105     urlbar.addEventListener("input", this);
  1106   },
  1108   endUrlbarCapture: function(aWindow) {
  1109     let urlbar = aWindow.document.getElementById("urlbar");
  1110     urlbar.removeEventListener("input", this);
  1111     this.urlbarCapture.delete(aWindow);
  1112   },
  1114   handleUrlbarInput: function(aWindow) {
  1115     if (!this.urlbarCapture.has(aWindow))
  1116       return;
  1118     let urlbar = aWindow.document.getElementById("urlbar");
  1120     let {expected, url} = this.urlbarCapture.get(aWindow);
  1122     if (urlbar.value.toLocaleLowerCase().localeCompare(expected) != 0)
  1123       return;
  1125     urlbar.handleRevert();
  1127     let tab = aWindow.gBrowser.addTab(url, {
  1128       owner: aWindow.gBrowser.selectedTab,
  1129       relatedToCurrent: true
  1130     });
  1131     aWindow.gBrowser.selectedTab = tab;
  1132   },
  1134   getConfiguration: function(aContentDocument, aConfiguration, aCallbackID) {
  1135     switch (aConfiguration) {
  1136       case "availableTargets":
  1137         this.getAvailableTargets(aContentDocument, aCallbackID);
  1138         break;
  1139       case "sync":
  1140         this.sendPageCallback(aContentDocument, aCallbackID, {
  1141           setup: Services.prefs.prefHasUserValue("services.sync.username"),
  1142         });
  1143         break;
  1144       default:
  1145         Cu.reportError("getConfiguration: Unknown configuration requested: " + aConfiguration);
  1146         break;
  1148   },
  1150   getAvailableTargets: function(aContentDocument, aCallbackID) {
  1151     let window = this.getChromeWindow(aContentDocument);
  1152     let data = this.availableTargetsCache.get(window);
  1153     if (data) {
  1154       this.sendPageCallback(aContentDocument, aCallbackID, data);
  1155       return;
  1158     let promises = [];
  1159     for (let targetName of this.targets.keys()) {
  1160       promises.push(this.getTarget(window, targetName));
  1162     Promise.all(promises).then((targetObjects) => {
  1163       let targetNames = [
  1164         "pinnedTab",
  1165       ];
  1166       for (let targetObject of targetObjects) {
  1167         if (targetObject.node)
  1168           targetNames.push(targetObject.targetName);
  1170       let data = {
  1171         targets: targetNames,
  1172       };
  1173       this.availableTargetsCache.set(window, data);
  1174       this.sendPageCallback(aContentDocument, aCallbackID, data);
  1175     }, (err) => {
  1176       Cu.reportError(err);
  1177       this.sendPageCallback(aContentDocument, aCallbackID, {
  1178         targets: [],
  1179       });
  1180     });
  1181   },
  1183   _addAnnotationPanelMutationObserver: function(aPanelEl) {
  1184 #ifdef XP_LINUX
  1185     let observer = this._annotationPanelMutationObservers.get(aPanelEl);
  1186     if (observer) {
  1187       return;
  1189     let win = aPanelEl.ownerDocument.defaultView;
  1190     observer = new win.MutationObserver(this._annotationMutationCallback);
  1191     this._annotationPanelMutationObservers.set(aPanelEl, observer);
  1192     let observerOptions = {
  1193       attributeFilter: ["height", "width"],
  1194       attributes: true,
  1195     };
  1196     observer.observe(aPanelEl, observerOptions);
  1197 #endif
  1198   },
  1200   _removeAnnotationPanelMutationObserver: function(aPanelEl) {
  1201 #ifdef XP_LINUX
  1202     let observer = this._annotationPanelMutationObservers.get(aPanelEl);
  1203     if (observer) {
  1204       observer.disconnect();
  1205       this._annotationPanelMutationObservers.delete(aPanelEl);
  1207 #endif
  1208   },
  1210 /**
  1211  * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
  1212  * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
  1213  * set on the panel.
  1214  */
  1215   _annotationMutationCallback: function(aMutations) {
  1216     for (let mutation of aMutations) {
  1217       // Remove both attributes at once and ignore remaining mutations to be proccessed.
  1218       mutation.target.removeAttribute("width");
  1219       mutation.target.removeAttribute("height");
  1220       return;
  1222   },
  1223 };
  1225 this.UITour.init();

mercurial