Thu, 22 Jan 2015 13:21:57 +0100
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;
1017 }
1018 if (aOpenCallback)
1019 menuBtn.addEventListener("popupshown", onPopupShown);
1020 menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true);
1021 }
1022 function onPopupShown(event) {
1023 this.removeEventListener("popupshown", onPopupShown);
1024 aOpenCallback(event);
1025 }
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);
1032 }
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);
1037 }
1038 aWindow.PanelUI.show();
1039 } else if (aMenuName == "bookmarks") {
1040 openMenuButton("bookmarks-menu-button");
1041 }
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);
1049 }
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");
1057 }
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;
1077 }
1078 hideMethod(win);
1079 }).then(null, Cu.reportError);
1080 }
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;
1093 }
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;
1147 }
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;
1156 }
1158 let promises = [];
1159 for (let targetName of this.targets.keys()) {
1160 promises.push(this.getTarget(window, targetName));
1161 }
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);
1169 }
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;
1188 }
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);
1206 }
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;
1221 }
1222 },
1223 };
1225 this.UITour.init();