 |
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