fune/browser/components/loop/content/shared/js/mixins.js

502 lines
16 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
// This mixin should be deprecated and a new solution implemented for
// processing menus and taking care of menu positioning globally. This
// new implementation should ensure all menus are positioned using the
// same method of positioning
var loop = loop || {};
loop.shared = loop.shared || {};
loop.shared.mixins = (function() {
"use strict";
/**
* Root object, by default set to window.
* @type {DOMWindow|Object}
*/
var rootObject = window;
/**
* Sets a new root object. This is useful for testing native DOM events so we
* can fake them. In beforeEach(), loop.shared.mixins.setRootObject is used to
* substitute a fake window, and in afterEach(), the real window object is
* replaced.
*
* @param {Object}
*/
function setRootObject(obj) {
rootObject = obj;
}
/**
* window.location mixin. Handles changes in the call url.
* Forces a reload of the page to ensure proper state of the webapp
*
* @type {Object}
*/
var UrlHashChangeMixin = {
componentDidMount: function() {
rootObject.addEventListener("hashchange", this.onUrlHashChange, false);
},
componentWillUnmount: function() {
rootObject.removeEventListener("hashchange", this.onUrlHashChange, false);
}
};
/**
* Document location mixin.
*
* @type {Object}
*/
var DocumentLocationMixin = {
locationReload: function() {
rootObject.location.reload();
}
};
/**
* Document title mixin.
*
* @type {Object}
*/
var DocumentTitleMixin = {
setTitle: function(newTitle) {
rootObject.document.title = newTitle;
}
};
/**
* Window close mixin, for more testable closing of windows. Instead of
* calling window.close() directly, use this mixin and call
* this.closeWindow from your component.
*
* @type {Object}
*
* @see setRootObject for info on how to unit test code that uses this mixin
*/
var WindowCloseMixin = {
closeWindow: function() {
rootObject.close();
}
};
/**
* Dropdown menu mixin.
*
* @param {Sring} [boundingBoxSelector] Selector that points to an element that
* defines the constraints this dropdown
* is shown within. If not provided,
* `document.body` is assumed to be the
* constraining element.
* @type {Object}
*/
var DropdownMenuMixin = function(boundingBoxSelector) {
return {
get documentBody() {
return rootObject.document.body;
},
getInitialState: function() {
return {
showMenu: false
};
},
/*
* Event listener callback in charge of closing panels when the users
* clicks on something that is not a dropdown trigger button or menu item.
*/
_onBodyClick: function(event) {
var menuButton = this.refs["menu-button"] && this.refs["menu-button"].getDOMNode();
if (this.refs.anchor) {
menuButton = this.refs.anchor.getDOMNode();
}
/*
* XXX Because the mixin is inherited by multiple components there are
* multiple such listeners at one time. This means that this.refs is not
* relevant when you click inside component A but the listener that is
* running is in component B and does not recognise event.target. This
* should be refactored to only be attached once to the document and use
* classList instead of refs.
*/
if (event.target.classList.contains("dropdown-menu-item") ||
event.target.classList.contains("dropdown-menu-button")) {
return;
}
if (event.target !== menuButton) {
this.setState({ showMenu: false });
}
},
_correctMenuPosition: function() {
var menu = this.refs.menu && this.refs.menu.getDOMNode();
if (!menu) {
return;
}
if (menu.style.maxWidth) {
menu.style.maxWidth = "none";
}
if (menu.style.maxHeight) {
menu.style.maxHeight = "none";
}
// Correct the position of the menu only if necessary.
var x, y, boundingBox, boundingRect;
// Amount of pixels that the dropdown needs to stay away from the edges of
// the page body.
var boundOffset = 4;
var menuNodeRect = menu.getBoundingClientRect();
// If the menu dimensions are constrained to a bounding element, instead of
// the document body, find that element.
if (boundingBoxSelector) {
boundingBox = this.documentBody.querySelector(boundingBoxSelector);
if (boundingBox) {
boundingRect = boundingBox.getBoundingClientRect();
}
}
if (!boundingRect) {
boundingRect = {
height: this.documentBody.offsetHeight,
left: 0,
top: 0,
width: this.documentBody.offsetWidth
};
}
// Make sure the menu position will be a certain fixed amount of pixels away
// from the border of the bounding box.
boundingRect.width -= boundOffset;
boundingRect.height -= boundOffset;
x = menuNodeRect.left;
y = menuNodeRect.top;
// If there's an anchor present, position it relative to it first.
var anchor = this.refs.anchor && this.refs.anchor.getDOMNode();
if (anchor) {
// XXXmikedeboer: at the moment we only support positioning centered above
// anchor node. Please add more modes as necessary.
var anchorNodeRect = anchor.getBoundingClientRect();
// Because we're _correcting_ the position of the dropdown, we assume that
// the node is positioned absolute at 0,0 coordinates (top left).
x = Math.floor(anchorNodeRect.left - (menuNodeRect.width / 2) + (anchorNodeRect.width / 2));
y = Math.floor(anchorNodeRect.top - menuNodeRect.height - anchorNodeRect.height);
}
var overflowX = false;
var overflowY = false;
// Check the horizontal overflow.
if (x + menuNodeRect.width > boundingRect.width) {
// Anchor positioning is already relative, so don't subtract it again.
x = Math.floor(boundingRect.width - ((anchor ? 0 : x) + menuNodeRect.width));
overflowX = true;
}
// Check the vertical overflow.
if (y + menuNodeRect.height > boundingRect.height) {
// Anchor positioning is already relative, so don't subtract it again.
y = Math.floor(boundingRect.height - ((anchor ? 0 : y) + menuNodeRect.height));
overflowY = true;
}
if (anchor || overflowX) {
// Set the maximum dimensions that the menu DOMNode may grow to. The
// content overflow style should be defined in CSS.
// Since we don't care much about horizontal overflow currently, this
// doesn't really do much for now.
if (menuNodeRect.width > boundingRect.width) {
menu.classList.add("overflow");
menu.style.maxWidth = boundingRect.width + "px";
}
menu.style.marginLeft = x + "px";
} else if (!menu.style.marginLeft) {
menu.style.marginLeft = "auto";
}
if (anchor || overflowY) {
if (menuNodeRect.height > (boundingRect.height + y)) {
menu.classList.add("overflow");
// Set the maximum dimensions that the menu DOMNode may grow to. The
// content overflow style should be defined in CSS.
menu.style.maxHeight = (boundingRect.height + y) + "px";
// Since we just adjusted the max-height of the menu - thus its actual
// height as well - we need to adjust its vertical offset with the same
// amount.
y += menuNodeRect.height - (boundingRect.height + y);
}
menu.style.marginTop = y + "px";
} else if (!menu.style.marginLeft) {
menu.style.marginTop = "auto";
}
// Added call to _repositionMenu() if it exists, to allow a component to
// add specific repositioning to a menu.
if (this._repositionMenu) {
this._repositionMenu();
}
menu.style.visibility = "visible";
},
componentDidMount: function() {
this.documentBody.addEventListener("click", this._onBodyClick);
rootObject.addEventListener("blur", this.hideDropdownMenu);
},
componentWillUnmount: function() {
this.documentBody.removeEventListener("click", this._onBodyClick);
rootObject.removeEventListener("blur", this.hideDropdownMenu);
},
showDropdownMenu: function() {
this.setState({showMenu: true}, this._correctMenuPosition);
},
hideDropdownMenu: function() {
this.setState({showMenu: false}, function() {
var menu = this.refs.menu && this.refs.menu.getDOMNode();
if (menu) {
menu.style.visibility = "hidden";
}
});
},
toggleDropdownMenu: function() {
this[this.state.showMenu ? "hideDropdownMenu" : "showDropdownMenu"]();
}
};
};
/**
* Document visibility mixin. Allows defining the following hooks for when the
* document visibility status changes:
*
* - {Function} onDocumentVisible For when the document becomes visible.
* - {Function} onDocumentHidden For when the document becomes hidden.
*
* @type {Object}
*/
var DocumentVisibilityMixin = {
_onDocumentVisibilityChanged: function(event) {
if (!this.isMounted()) {
return;
}
var hidden = event.target.hidden;
if (hidden && typeof this.onDocumentHidden === "function") {
this.onDocumentHidden();
}
if (!hidden && typeof this.onDocumentVisible === "function") {
this.onDocumentVisible();
}
},
componentDidMount: function() {
rootObject.document.addEventListener(
"visibilitychange", this._onDocumentVisibilityChanged);
// Assume that the consumer components is only mounted when the document
// has become visible.
this._onDocumentVisibilityChanged({ target: rootObject.document });
},
componentWillUnmount: function() {
rootObject.document.removeEventListener(
"visibilitychange", this._onDocumentVisibilityChanged);
}
};
/**
* Media setup mixin. Provides a common location for settings for the media
* elements.
*/
var MediaSetupMixin = {
/**
* Returns the default configuration for publishing media on the sdk.
*
* @param {Object} options An options object containing:
* - publishVideo A boolean set to true to publish video when the stream is initiated.
*/
getDefaultPublisherConfig: function(options) {
options = options || {};
if (!("publishVideo" in options)) {
throw new Error("missing option publishVideo");
}
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
return {
insertMode: "append",
fitMode: "contain",
width: "100%",
height: "100%",
publishVideo: options.publishVideo,
showControls: false
};
}
};
/**
* Audio mixin. Allows playing a single audio file and ensuring it
* is stopped when the component is unmounted.
*/
var AudioMixin = {
audio: null,
_audioRequest: null,
_isLoopDesktop: function() {
return rootObject.navigator &&
typeof rootObject.navigator.mozLoop === "object";
},
/**
* Starts playing an audio file, stopping any audio that is already in progress.
*
* @param {String} name The filename to play (excluding the extension).
* @param {Object} options A list of options for the sound:
* - {Boolean} loop Whether or not to loop the sound.
*/
play: function(name, options) {
if (this._isLoopDesktop() && rootObject.navigator.mozLoop.doNotDisturb) {
return;
}
options = options || {};
options.loop = options.loop || false;
this._ensureAudioStopped();
this._getAudioBlob(name, function(error, blob) {
if (error) {
console.error(error);
return;
}
var url = URL.createObjectURL(blob);
this.audio = new Audio(url);
this.audio.loop = options.loop;
this.audio.play();
}.bind(this));
},
_getAudioBlob: function(name, callback) {
if (this._isLoopDesktop()) {
rootObject.navigator.mozLoop.getAudioBlob(name, callback);
return;
}
var url = "shared/sounds/" + name + ".ogg";
this._audioRequest = new XMLHttpRequest();
this._audioRequest.open("GET", url, true);
this._audioRequest.responseType = "arraybuffer";
this._audioRequest.onload = function() {
var request = this._audioRequest;
var error;
if (request.status < 200 || request.status >= 300) {
error = new Error(request.status + " " + request.statusText);
callback(error);
return;
}
var type = request.getResponseHeader("Content-Type");
var blob = new Blob([request.response], {type: type});
callback(null, blob);
}.bind(this);
this._audioRequest.send(null);
},
/**
* Ensures audio is stopped playing, and removes the object from memory.
*/
_ensureAudioStopped: function() {
if (this._audioRequest) {
this._audioRequest.abort();
delete this._audioRequest;
}
if (this.audio) {
this.audio.pause();
this.audio.removeAttribute("src");
delete this.audio;
}
},
/**
* Ensures audio is stopped when the component is unmounted.
*/
componentWillUnmount: function() {
this._ensureAudioStopped();
}
};
/**
* A mixin especially for rooms. This plays the right sound according to
* the state changes. Requires AudioMixin to also be used.
*/
var RoomsAudioMixin = {
mixins: [AudioMixin],
componentWillUpdate: function(nextProps, nextState) {
var ROOM_STATES = loop.store.ROOM_STATES;
function isConnectedToRoom(state) {
return state === ROOM_STATES.HAS_PARTICIPANTS ||
state === ROOM_STATES.SESSION_CONNECTED;
}
function notConnectedToRoom(state) {
// Failed and full are states that the user is not
// really connected to the room, but we don't want to
// catch those here, as they get their own sounds.
return state === ROOM_STATES.INIT ||
state === ROOM_STATES.GATHER ||
state === ROOM_STATES.READY ||
state === ROOM_STATES.JOINED ||
state === ROOM_STATES.ENDED;
}
// Joining the room.
if (notConnectedToRoom(this.state.roomState) &&
isConnectedToRoom(nextState.roomState)) {
this.play("room-joined");
}
// Other people coming and leaving.
if (this.state.roomState === ROOM_STATES.SESSION_CONNECTED &&
nextState.roomState === ROOM_STATES.HAS_PARTICIPANTS) {
this.play("room-joined-in");
}
if (this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS &&
nextState.roomState === ROOM_STATES.SESSION_CONNECTED) {
this.play("room-left");
}
// Leaving the room - same sound as if a participant leaves
if (isConnectedToRoom(this.state.roomState) &&
notConnectedToRoom(nextState.roomState)) {
this.play("room-left");
}
// Room failures
if (nextState.roomState === ROOM_STATES.FAILED ||
nextState.roomState === ROOM_STATES.FULL) {
this.play("failure");
}
}
};
return {
AudioMixin: AudioMixin,
RoomsAudioMixin: RoomsAudioMixin,
setRootObject: setRootObject,
DropdownMenuMixin: DropdownMenuMixin,
DocumentVisibilityMixin: DocumentVisibilityMixin,
DocumentLocationMixin: DocumentLocationMixin,
DocumentTitleMixin: DocumentTitleMixin,
MediaSetupMixin: MediaSetupMixin,
UrlHashChangeMixin: UrlHashChangeMixin,
WindowCloseMixin: WindowCloseMixin
};
})();