Mozilla Cross-Reference mozilla-central
mozilla/ browser/ base/ content/ browser-loop.js
Hg Log
Hg Blame
Diff file
Raw file
view using tree:
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/.
4 
5 // the "exported" symbols
6 let LoopUI;
7 
8 XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI", "resource:///modules/loop/MozLoopAPI.jsm");
9 XPCOMUtils.defineLazyModuleGetter(this, "LoopRooms", "resource:///modules/loop/LoopRooms.jsm");
10 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService", "resource:///modules/loop/MozLoopService.jsm");
11 XPCOMUtils.defineLazyModuleGetter(this, "PanelFrame", "resource:///modules/PanelFrame.jsm");
12 
13 
14 (function() {
15   const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
16   const kBrowserSharingNotificationId = "loop-sharing-notification";
17   const kPrefBrowserSharingInfoBar = "browserSharing.showInfoBar";
18 
19   LoopUI = {
20     /**
21      * @var {XULWidgetSingleWrapper} toolbarButton Getter for the Loop toolbarbutton
22      *                                             instance for this window.
23      */
24     get toolbarButton() {
25       delete this.toolbarButton;
26       return this.toolbarButton = CustomizableUI.getWidget("loop-button").forWindow(window);
27     },
28 
29     /**
30      * @var {XULElement} panel Getter for the Loop panel element.
31      */
32     get panel() {
33       delete this.panel;
34       return this.panel = document.getElementById("loop-notification-panel");
35     },
36 
37     /**
38      * @var {XULElement|null} browser Getter for the Loop panel browser element.
39      *                                Will be NULL if the panel hasn't loaded yet.
40      */
41     get browser() {
42       let browser = document.querySelector("#loop-notification-panel > #loop-panel-iframe");
43       if (browser) {
44         delete this.browser;
45         this.browser = browser;
46       }
47       return browser;
48     },
49 
50     /**
51      * @var {String|null} selectedTab Getter for the name of the currently selected
52      *                                tab inside the Loop panel. Will be NULL if
53      *                                the panel hasn't loaded yet.
54      */
55     get selectedTab() {
56       if (!this.browser) {
57         return null;
58       }
59 
60       let selectedTab = this.browser.contentDocument.querySelector(".tab-view > .selected");
61       return selectedTab && selectedTab.getAttribute("data-tab-name");
62     },
63 
64     /**
65      * @return {Promise}
66      */
67     promiseDocumentVisible(aDocument) {
68       if (!aDocument.hidden) {
69         return Promise.resolve();
70       }
71 
72       return new Promise((resolve) => {
73         aDocument.addEventListener("visibilitychange", function onVisibilityChanged() {
74           aDocument.removeEventListener("visibilitychange", onVisibilityChanged);
75           resolve();
76         });
77       });
78     },
79 
80     /**
81      * Toggle between opening or hiding the Loop panel.
82      *
83      * @param {DOMEvent} [event] Optional event that triggered the call to this
84      *                           function.
85      * @param {String}   [tabId] Optional name of the tab to select after the panel
86      *                           has opened. Does nothing when the panel is hidden.
87      * @return {Promise}
88      */
89     togglePanel: function(event, tabId = null) {
90       if (this.panel.state == "open") {
91         return new Promise(resolve => {
92           this.panel.hidePopup();
93           resolve();
94         });
95       }
96 
97       return this.openCallPanel(event, tabId);
98     },
99 
100     /**
101      * Opens the panel for Loop and sizes it appropriately.
102      *
103      * @param {event}  event   The event opening the panel, used to anchor
104      *                         the panel to the button which triggers it.
105      * @param {String} [tabId] Identifier of the tab to select when the panel is
106      *                         opened. Example: 'rooms', 'contacts', etc.
107      * @return {Promise}
108      */
109     openCallPanel: function(event, tabId = null) {
110       return new Promise((resolve) => {
111         let callback = iframe => {
112           // Helper function to show a specific tab view in the panel.
113           function showTab() {
114             if (!tabId) {
115               resolve(LoopUI.promiseDocumentVisible(iframe.contentDocument));
116               return;
117             }
118 
119             let win = iframe.contentWindow;
120             let ev = new win.CustomEvent("UIAction", Cu.cloneInto({
121               detail: {
122                 action: "selectTab",
123                 tab: tabId
124               }
125             }, win));
126             win.dispatchEvent(ev);
127             resolve(LoopUI.promiseDocumentVisible(iframe.contentDocument));
128           }
129 
130           // If the panel has been opened and initialized before, we can skip waiting
131           // for the content to load - because it's already there.
132           if (("contentWindow" in iframe) && iframe.contentWindow.document.readyState == "complete") {
133             showTab();
134             return;
135           }
136 
137           iframe.addEventListener("DOMContentLoaded", function documentDOMLoaded() {
138             iframe.removeEventListener("DOMContentLoaded", documentDOMLoaded, true);
139             injectLoopAPI(iframe.contentWindow);
140             iframe.contentWindow.addEventListener("loopPanelInitialized", function loopPanelInitialized() {
141               iframe.contentWindow.removeEventListener("loopPanelInitialized",
142                                                        loopPanelInitialized);
143               showTab();
144             });
145           }, true);
146         };
147 
148         // Used to clear the temporary "login" state from the button.
149         Services.obs.notifyObservers(null, "loop-status-changed", null);
150 
151         this.shouldResumeTour().then((resume) => {
152           if (resume) {
153             // Assume the conversation with the visitor wasn't open since we would
154             // have resumed the tour as soon as the visitor joined if it was (and
155             // the pref would have been set to false already.
156             MozLoopService.resumeTour("waiting");
157             resolve();
158             return;
159           }
160 
161           PanelFrame.showPopup(window, event ? event.target : this.toolbarButton.node,
162                                "loop", null, "about:looppanel", null, callback);
163         });
164       });
165     },
166 
167     /**
168      * Method to know whether actions to open the panel should instead resume the tour.
169      *
170      * We need the panel to be opened via UITour so that it gets @noautohide.
171      *
172      * @return {Promise} resolving with a {Boolean} of whether the tour should be resumed instead of
173      *                   opening the panel.
174      */
175     shouldResumeTour: Task.async(function* () {
176       // Resume the FTU tour if this is the first time a room was joined by
177       // someone else since the tour.
178       if (!Services.prefs.getBoolPref("loop.gettingStarted.resumeOnFirstJoin")) {
179         return false;
180       }
181 
182       if (!LoopRooms.participantsCount) {
183         // Nobody is in the rooms
184         return false;
185       }
186 
187       let roomsWithNonOwners = yield this.roomsWithNonOwners();
188       if (!roomsWithNonOwners.length) {
189         // We were the only one in a room but we want to know about someone else joining.
190         return false;
191       }
192 
193       return true;
194     }),
195 
196     /**
197      * @return {Promise} resolved with an array of Rooms with participants (excluding owners)
198      */
199     roomsWithNonOwners: function() {
200       return new Promise(resolve => {
201         LoopRooms.getAll((error, rooms) => {
202           let roomsWithNonOwners = [];
203           for (let room of rooms) {
204             if (!("participants" in room)) {
205               continue;
206             }
207             let numNonOwners = room.participants.filter(participant => !participant.owner).length;
208             if (!numNonOwners) {
209               continue;
210             }
211             roomsWithNonOwners.push(room);
212           }
213           resolve(roomsWithNonOwners);
214         });
215       });
216     },
217 
218     /**
219      * Triggers the initialization of the loop service.  Called by
220      * delayedStartup.
221      */
222     init: function() {
223       // Add observer notifications before the service is initialized
224       Services.obs.addObserver(this, "loop-status-changed", false);
225 
226       MozLoopService.initialize();
227       this.updateToolbarState();
228     },
229 
230     uninit: function() {
231       Services.obs.removeObserver(this, "loop-status-changed");
232     },
233 
234     // Implements nsIObserver
235     observe: function(subject, topic, data) {
236       if (topic != "loop-status-changed") {
237         return;
238       }
239       this.updateToolbarState(data);
240     },
241 
242     /**
243      * Updates the toolbar/menu-button state to reflect Loop status.
244      *
245      * @param {string} [aReason] Some states are only shown if
246      *                           a related reason is provided.
247      *
248      *                 aReason="login": Used after a login is completed
249      *                   successfully. This is used so the state can be
250      *                   temporarily shown until the next state change.
251      */
252     updateToolbarState: function(aReason = null) {
253       if (!this.toolbarButton.node) {
254         return;
255       }
256       let state = "";
257       if (MozLoopService.errors.size) {
258         state = "error";
259       } else if (MozLoopService.screenShareActive) {
260         state = "action";
261       } else if (aReason == "login" && MozLoopService.userProfile) {
262         state = "active";
263       } else if (MozLoopService.doNotDisturb) {
264         state = "disabled";
265       } else if (MozLoopService.roomsParticipantsCount > 0) {
266         state = "active";
267       }
268       this.toolbarButton.node.setAttribute("state", state);
269     },
270 
271     /**
272      * Show a desktop notification when 'do not disturb' isn't enabled.
273      *
274      * @param {Object} options Set of options that may tweak the appearance and
275      *                         behavior of the notification.
276      *                         Option params:
277      *                         - {String}   title       Notification title message
278      *                         - {String}   [message]   Notification body text
279      *                         - {String}   [icon]      Notification icon
280      *                         - {String}   [sound]     Sound to play
281      *                         - {String}   [selectTab] Tab to select when the panel
282      *                                                  opens
283      *                         - {Function} [onclick]   Callback to invoke when
284      *                                                  the notification is clicked.
285      *                                                  Opens the panel by default.
286      */
287     showNotification: function(options) {
288       if (MozLoopService.doNotDisturb) {
289         return;
290       }
291 
292       if (!options.title) {
293         throw new Error("Missing title, can not display notification");
294       }
295 
296       let notificationOptions = {
297         body: options.message || ""
298       };
299       if (options.icon) {
300         notificationOptions.icon = options.icon;
301       }
302       if (options.sound) {
303         // This will not do anything, until bug bug 1105222 is resolved.
304         notificationOptions.mozbehavior = {
305           soundFile: ""
306         };
307         this.playSound(options.sound);
308       }
309 
310       let notification = new window.Notification(options.title, notificationOptions);
311       notification.addEventListener("click", e => {
312         if (window.closed) {
313           return;
314         }
315 
316         try {
317           window.focus();
318         } catch (ex) {}
319 
320         // We need a setTimeout here, otherwise the panel won't show after the
321         // window received focus.
322         window.setTimeout(() => {
323           if (typeof options.onclick == "function") {
324             options.onclick();
325           } else {
326             // Open the Loop panel as a default action.
327             this.openCallPanel(null, options.selectTab || null);
328           }
329         }, 0);
330       });
331     },
332 
333     /**
334      * Play a sound in this window IF there's no sound playing yet.
335      *
336      * @param {String} name Name of the sound, like 'ringtone' or 'room-joined'
337      */
338     playSound: function(name) {
339       if (this.ActiveSound || MozLoopService.doNotDisturb) {
340         return;
341       }
342 
343       this.activeSound = new window.Audio();
344       this.activeSound.src = `chrome://browser/content/loop/shared/sounds/${name}.ogg`;
345       this.activeSound.load();
346       this.activeSound.play();
347 
348       this.activeSound.addEventListener("ended", () => this.activeSound = undefined, false);
349     },
350 
351     /**
352      * Adds a listener for browser sharing. It will inform the listener straight
353      * away for the current windowId, and then on every tab change.
354      *
355      * Listener parameters:
356      * - {Object}  err       If there is a error this will be defined, null otherwise.
357      * - {Integer} windowId  The new windowId for the browser.
358      *
359      * @param {Function} listener The listener to receive information on when the
360      *                            windowId changes.
361      */
362     addBrowserSharingListener: function(listener) {
363       if (!this._tabChangeListeners) {
364         this._tabChangeListeners = new Set();
365         gBrowser.tabContainer.addEventListener("TabSelect", this);
366       }
367 
368       this._tabChangeListeners.add(listener);
369       this._maybeShowBrowserSharingInfoBar();
370 
371       // Get the first window Id for the listener.
372       listener(null, gBrowser.selectedBrowser.outerWindowID);
373     },
374 
375     /**
376      * Removes a listener from browser sharing.
377      *
378      * @param {Function} listener The listener to remove from the list.
379      */
380     removeBrowserSharingListener: function(listener) {
381       if (!this._tabChangeListeners) {
382         return;
383       }
384 
385       if (this._tabChangeListeners.has(listener)) {
386         this._tabChangeListeners.delete(listener);
387       }
388 
389       if (!this._tabChangeListeners.size) {
390         this._hideBrowserSharingInfoBar();
391         gBrowser.tabContainer.removeEventListener("TabSelect", this);
392         delete this._tabChangeListeners;
393       }
394     },
395 
396     /**
397      * Helper function to fetch a localized string via the MozLoopService API.
398      * It's currently inconveniently wrapped inside a string of stringified JSON.
399      *
400      * @param  {String} key The element id to get strings for.
401      * @return {String}
402      */
403     _getString: function(key) {
404       let str = MozLoopService.getStrings(key);
405       if (str) {
406         str = JSON.parse(str).textContent;
407       }
408       return str;
409     },
410 
411     /**
412      * Shows an infobar notification at the top of the browser window that warns
413      * the user that their browser tabs are being broadcasted through the current
414      * conversation.
415      */
416     _maybeShowBrowserSharingInfoBar: function() {
417       this._hideBrowserSharingInfoBar();
418 
419       // Don't show the infobar if it's been permanently disabled from the menu.
420       if (!MozLoopService.getLoopPref(kPrefBrowserSharingInfoBar)) {
421         return;
422       }
423 
424       // Create the menu that is shown when the menu-button' dropmarker is clicked
425       // inside the notification bar.
426       let menuPopup = document.createElementNS(kNSXUL, "menupopup");
427       let menuItem = menuPopup.appendChild(document.createElementNS(kNSXUL, "menuitem"));
428       menuItem.setAttribute("label", this._getString("infobar_menuitem_dontshowagain_label"));
429       menuItem.setAttribute("accesskey", this._getString("infobar_menuitem_dontshowagain_accesskey"));
430       menuItem.addEventListener("command", () => {
431         // We're being told to hide the bar permanently.
432         this._hideBrowserSharingInfoBar(true);
433       });
434 
435       let box = gBrowser.getNotificationBox();
436       let bar = box.appendNotification(
437         this._getString("infobar_screenshare_browser_message"),
438         kBrowserSharingNotificationId,
439         // Icon is defined in browser theme CSS.
440         null,
441         box.PRIORITY_WARNING_LOW,
442         [{
443           label: this._getString("infobar_button_gotit_label"),
444           accessKey: this._getString("infobar_button_gotit_accesskey"),
445           type: "menu-button",
446           popup: menuPopup,
447           anchor: "dropmarker",
448           callback: () => {
449             this._hideBrowserSharingInfoBar();
450           }
451         }]
452       );
453 
454       // Keep showing the notification bar until the user explicitly closes it.
455       bar.persistence = -1;
456     },
457 
458     /**
459      * Hides the infobar, permanantly if requested.
460      *
461      * @param {Boolean} permanently Flag that determines if the infobar will never
462      *                              been shown again. Defaults to `false`.
463      * @return {Boolean} |true| if the infobar was hidden here.
464      */
465     _hideBrowserSharingInfoBar: function(permanently = false, browser) {
466       browser = browser || gBrowser.selectedBrowser;
467       let box = gBrowser.getNotificationBox(browser);
468       let notification = box.getNotificationWithValue(kBrowserSharingNotificationId);
469       let removed = false;
470       if (notification) {
471         box.removeNotification(notification);
472         removed = true;
473       }
474 
475       if (permanently) {
476         MozLoopService.setLoopPref(kPrefBrowserSharingInfoBar, false);
477       }
478 
479       return removed;
480     },
481 
482     /**
483      * Handles events from gBrowser.
484      */
485     handleEvent: function(event) {
486       // We only should get "select" events.
487       if (event.type != "TabSelect") {
488         return;
489       }
490 
491       let wasVisible = false;
492       // Hide the infobar from the previous tab.
493       if (event.detail.previousTab) {
494         wasVisible = this._hideBrowserSharingInfoBar(false, event.detail.previousTab.linkedBrowser);
495       }
496 
497       // We've changed the tab, so get the new window id.
498       for (let listener of this._tabChangeListeners) {
499         try {
500           listener(null, gBrowser.selectedBrowser.outerWindowID);
501         } catch (ex) {
502           Cu.reportError("Tab switch caused an error: " + ex.message);
503         }
504       };
505 
506       if (wasVisible) {
507         // If the infobar was visible before, we should show it again after the
508         // switch.
509         this._maybeShowBrowserSharingInfoBar();
510       }
511     },
512   };
513 })();
514 
view http://hg.mozilla.org/mozilla-central/rev/ /browser/base/content/browser-loop.js