Bug 1074702 - Part 2: Room views for Loop standalone. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-11-12 15:20:54 +00:00
parent 131e1b12f1
commit 7c60277a5e
12 changed files with 587 additions and 52 deletions

View File

@ -725,3 +725,59 @@ html, .fx-embedded, #main,
position: absolute;
bottom: 10px;
}
/* Standalone rooms */
.standalone .room-conversation-wrapper {
position: relative;
}
.standalone .room-inner-action-area {
position: absolute;
top: 35%;
z-index: 1000;
margin: 0 auto;
width: 50%;
}
.standalone .room-inner-action-area button {
position: absolute;
border-radius: 3px;
font-size: 1.2em;
padding: .2em 1.2em;
cursor: pointer;
}
.standalone .room-conversation h2.room-name {
position: absolute;
display: inline-block;
top: 0;
right: 0;
color: #fff;
z-index: 2000000;
font-size: 1.2em;
padding: .4em;
}
.standalone .room-conversation .media {
background: #000;
}
.standalone .room-conversation .video_wrapper.remote_wrapper {
background-color: #4e4e4e;
width: 75%;
}
.standalone .room-conversation .local-stream {
width: 33%;
height: 26.5%;
}
.standalone .room-conversation .conversation-toolbar {
background: #000;
border-top: none;
}
.standalone .room-conversation .conversation-toolbar .btn-hangup-entry {
display: block;
}

View File

@ -81,7 +81,8 @@ loop.shared.views = (function(_, OT, l10n) {
getDefaultProps: function() {
return {
video: {enabled: true, visible: true},
audio: {enabled: true, visible: true}
audio: {enabled: true, visible: true},
enableHangup: true
};
},
@ -89,7 +90,9 @@ loop.shared.views = (function(_, OT, l10n) {
video: React.PropTypes.object.isRequired,
audio: React.PropTypes.object.isRequired,
hangup: React.PropTypes.func.isRequired,
publishStream: React.PropTypes.func.isRequired
publishStream: React.PropTypes.func.isRequired,
hangupButtonLabel: React.PropTypes.string,
enableHangup: React.PropTypes.bool,
},
handleClickHangup: function() {
@ -104,14 +107,19 @@ loop.shared.views = (function(_, OT, l10n) {
this.props.publishStream("audio", !this.props.audio.enabled);
},
_getHangupButtonLabel: function() {
return this.props.hangupButtonLabel || l10n.get("hangup_button_caption2");
},
render: function() {
var cx = React.addons.classSet;
return (
React.DOM.ul({className: "conversation-toolbar"},
React.DOM.li({className: "conversation-toolbar-btn-box btn-hangup-entry"},
React.DOM.button({className: "btn btn-hangup", onClick: this.handleClickHangup,
title: l10n.get("hangup_button_title")},
l10n.get("hangup_button_caption2")
title: l10n.get("hangup_button_title"),
disabled: !this.props.enableHangup},
this._getHangupButtonLabel()
)
),
React.DOM.li({className: "conversation-toolbar-btn-box"},

View File

@ -81,7 +81,8 @@ loop.shared.views = (function(_, OT, l10n) {
getDefaultProps: function() {
return {
video: {enabled: true, visible: true},
audio: {enabled: true, visible: true}
audio: {enabled: true, visible: true},
enableHangup: true
};
},
@ -89,7 +90,9 @@ loop.shared.views = (function(_, OT, l10n) {
video: React.PropTypes.object.isRequired,
audio: React.PropTypes.object.isRequired,
hangup: React.PropTypes.func.isRequired,
publishStream: React.PropTypes.func.isRequired
publishStream: React.PropTypes.func.isRequired,
hangupButtonLabel: React.PropTypes.string,
enableHangup: React.PropTypes.bool,
},
handleClickHangup: function() {
@ -104,14 +107,19 @@ loop.shared.views = (function(_, OT, l10n) {
this.props.publishStream("audio", !this.props.audio.enabled);
},
_getHangupButtonLabel: function() {
return this.props.hangupButtonLabel || l10n.get("hangup_button_caption2");
},
render: function() {
var cx = React.addons.classSet;
return (
<ul className="conversation-toolbar">
<li className="conversation-toolbar-btn-box btn-hangup-entry">
<button className="btn btn-hangup" onClick={this.handleClickHangup}
title={l10n.get("hangup_button_title")}>
{l10n.get("hangup_button_caption2")}
title={l10n.get("hangup_button_title")}
disabled={!this.props.enableHangup}>
{this._getHangupButtonLabel()}
</button>
</li>
<li className="conversation-toolbar-btn-box">

View File

@ -7,11 +7,12 @@
/* global loop:true, React */
var loop = loop || {};
loop.standaloneRoomViews = (function() {
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views;
var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView',
mixins: [Backbone.Events],
@ -23,7 +24,11 @@ loop.standaloneRoomViews = (function() {
},
getInitialState: function() {
return this.props.activeRoomStore.getStoreState();
var storeState = this.props.activeRoomStore.getStoreState();
return _.extend({}, storeState, {
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
});
},
componentWillMount: function() {
@ -41,10 +46,57 @@ loop.standaloneRoomViews = (function() {
this.setState(this.props.activeRoomStore.getStoreState());
},
/**
* Returns either the required DOMNode
*
* @param {String} className The name of the class to get the element for.
*/
_getElement: function(className) {
return this.getDOMNode().querySelector(className);
},
/**
* Returns the required configuration for publishing video on the sdk.
*/
_getPublisherConfig: function() {
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
return {
insertMode: "append",
width: "100%",
height: "100%",
publishVideo: true,
style: {
audioLevelDisplayMode: "off",
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off",
videoDisabledDisplayMode: "off"
}
};
},
componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore);
},
/**
* Watches for when we transition from READY to JOINED room state, so we can
* request user media access.
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState === ROOM_STATES.READY &&
nextState.roomState === ROOM_STATES.JOINED) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
}
},
joinRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
},
@ -53,30 +105,82 @@ loop.standaloneRoomViews = (function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
// XXX Implement tests for this view when we do the proper views
// - bug 1074705 and others
render: function() {
switch(this.state.roomState) {
case ROOM_STATES.READY: {
return (
React.DOM.div(null, React.DOM.button({onClick: this.joinRoom}, "Join"))
);
}
case ROOM_STATES.JOINED: {
return (
React.DOM.div(null, React.DOM.button({onClick: this.leaveRoom}, "Leave"))
);
}
default: {
return (
React.DOM.div(null, this.state.roomState)
);
}
/**
* Toggles streaming status for a given stream type.
*
* @param {String} type Stream type ("audio" or "video").
* @param {Boolean} enabled Enabled stream flag.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
/**
* Checks if current room is active.
*
* @return {Boolean}
*/
_roomIsActive: function() {
return this.state.roomState === ROOM_STATES.JOINED ||
this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
},
_renderActionButtons: function() {
// XXX Render "Start your own" button when room is over capacity (see
// bug 1074709)
if (this.state.roomState === ROOM_STATES.INIT ||
this.state.roomState === ROOM_STATES.READY) {
return (
React.DOM.div({className: "room-inner-action-area"},
React.DOM.button({className: "btn btn-join btn-info", onClick: this.joinRoom},
mozL10n.get("rooms_room_join_label")
)
)
);
}
},
render: function() {
var localStreamClasses = React.addons.classSet({
hide: !this._roomIsActive(),
local: true,
"local-stream": true,
"local-stream-audio": false
});
return (
React.DOM.div({className: "room-conversation-wrapper"},
this._renderActionButtons(),
React.DOM.div({className: "video-layout-wrapper"},
React.DOM.div({className: "conversation room-conversation"},
React.DOM.h2({className: "room-name"}, this.state.roomName),
React.DOM.div({className: "media nested"},
React.DOM.div({className: "video_wrapper remote_wrapper"},
React.DOM.div({className: "video_inner remote"})
),
React.DOM.div({className: localStreamClasses})
),
sharedViews.ConversationToolbar({
video: {enabled: !this.state.videoMuted,
visible: this._roomIsActive()},
audio: {enabled: !this.state.audioMuted,
visible: this._roomIsActive()},
publishStream: this.publishStream,
hangup: this.leaveRoom,
hangupButtonLabel: mozL10n.get("rooms_leave_button_label"),
enableHangup: this._roomIsActive()})
)
)
)
);
}
});
return {
StandaloneRoomView: StandaloneRoomView
};
})();
})(navigator.mozL10n);

View File

@ -7,11 +7,12 @@
/* global loop:true, React */
var loop = loop || {};
loop.standaloneRoomViews = (function() {
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views;
var StandaloneRoomView = React.createClass({
mixins: [Backbone.Events],
@ -23,7 +24,11 @@ loop.standaloneRoomViews = (function() {
},
getInitialState: function() {
return this.props.activeRoomStore.getStoreState();
var storeState = this.props.activeRoomStore.getStoreState();
return _.extend({}, storeState, {
// Used by the UI showcase.
roomState: this.props.roomState || storeState.roomState
});
},
componentWillMount: function() {
@ -41,10 +46,57 @@ loop.standaloneRoomViews = (function() {
this.setState(this.props.activeRoomStore.getStoreState());
},
/**
* Returns either the required DOMNode
*
* @param {String} className The name of the class to get the element for.
*/
_getElement: function(className) {
return this.getDOMNode().querySelector(className);
},
/**
* Returns the required configuration for publishing video on the sdk.
*/
_getPublisherConfig: function() {
// height set to 100%" to fix video layout on Google Chrome
// @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
return {
insertMode: "append",
width: "100%",
height: "100%",
publishVideo: true,
style: {
audioLevelDisplayMode: "off",
bugDisplayMode: "off",
buttonDisplayMode: "off",
nameDisplayMode: "off",
videoDisabledDisplayMode: "off"
}
};
},
componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore);
},
/**
* Watches for when we transition from READY to JOINED room state, so we can
* request user media access.
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState === ROOM_STATES.READY &&
nextState.roomState === ROOM_STATES.JOINED) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),
getLocalElementFunc: this._getElement.bind(this, ".local"),
getRemoteElementFunc: this._getElement.bind(this, ".remote")
}));
}
},
joinRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
},
@ -53,30 +105,82 @@ loop.standaloneRoomViews = (function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
// XXX Implement tests for this view when we do the proper views
// - bug 1074705 and others
render: function() {
switch(this.state.roomState) {
case ROOM_STATES.READY: {
return (
<div><button onClick={this.joinRoom}>Join</button></div>
);
}
case ROOM_STATES.JOINED: {
return (
<div><button onClick={this.leaveRoom}>Leave</button></div>
);
}
default: {
return (
<div>{this.state.roomState}</div>
);
}
/**
* Toggles streaming status for a given stream type.
*
* @param {String} type Stream type ("audio" or "video").
* @param {Boolean} enabled Enabled stream flag.
*/
publishStream: function(type, enabled) {
this.props.dispatcher.dispatch(new sharedActions.SetMute({
type: type,
enabled: enabled
}));
},
/**
* Checks if current room is active.
*
* @return {Boolean}
*/
_roomIsActive: function() {
return this.state.roomState === ROOM_STATES.JOINED ||
this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
},
_renderActionButtons: function() {
// XXX Render "Start your own" button when room is over capacity (see
// bug 1074709)
if (this.state.roomState === ROOM_STATES.INIT ||
this.state.roomState === ROOM_STATES.READY) {
return (
<div className="room-inner-action-area">
<button className="btn btn-join btn-info" onClick={this.joinRoom}>
{mozL10n.get("rooms_room_join_label")}
</button>
</div>
);
}
},
render: function() {
var localStreamClasses = React.addons.classSet({
hide: !this._roomIsActive(),
local: true,
"local-stream": true,
"local-stream-audio": false
});
return (
<div className="room-conversation-wrapper">
{this._renderActionButtons()}
<div className="video-layout-wrapper">
<div className="conversation room-conversation">
<h2 className="room-name">{this.state.roomName}</h2>
<div className="media nested">
<div className="video_wrapper remote_wrapper">
<div className="video_inner remote"></div>
</div>
<div className={localStreamClasses}></div>
</div>
<sharedViews.ConversationToolbar
video={{enabled: !this.state.videoMuted,
visible: this._roomIsActive()}}
audio={{enabled: !this.state.audioMuted,
visible: this._roomIsActive()}}
publishStream={this.publishStream}
hangup={this.leaveRoom}
hangupButtonLabel={mozL10n.get("rooms_leave_button_label")}
enableHangup={this._roomIsActive()} />
</div>
</div>
</div>
);
}
});
return {
StandaloneRoomView: StandaloneRoomView
};
})();
})(navigator.mozL10n);

View File

@ -101,6 +101,28 @@ describe("loop.shared.views", function() {
publishStream = sandbox.stub();
});
it("should accept a hangupButtonLabel optional prop", function() {
var comp = mountTestComponent({
hangupButtonLabel: "foo",
hangup: hangup,
publishStream: publishStream
});
expect(comp.getDOMNode().querySelector("button.btn-hangup").textContent)
.eql("foo");
});
it("should accept a enableHangup optional prop", function() {
var comp = mountTestComponent({
enableHangup: false,
hangup: hangup,
publishStream: publishStream
});
expect(comp.getDOMNode().querySelector("button.btn-hangup").disabled)
.eql(true);
});
it("should hangup when hangup button is clicked", function() {
var comp = mountTestComponent({
hangup: hangup,

View File

@ -52,6 +52,7 @@
<script src="standalone_client_test.js"></script>
<script src="standaloneAppStore_test.js"></script>
<script src="standaloneMozLoop_test.js"></script>
<script src="standaloneRoomViews_test.js"></script>
<script src="webapp_test.js"></script>
<script src="multiplexGum_test.js"></script>
<script>

View File

@ -0,0 +1,184 @@
/* 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/. */
/* global loop, sinon */
var expect = chai.expect;
describe("loop.standaloneRoomViews", function() {
"use strict";
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sandbox, dispatcher, activeRoomStore, dispatch;
beforeEach(function() {
sandbox = sinon.sandbox.create();
dispatcher = new loop.Dispatcher();
dispatch = sandbox.stub(dispatcher, "dispatch");
activeRoomStore = new loop.store.ActiveRoomStore({
dispatcher: dispatcher,
mozLoop: {},
sdkDriver: {}
});
});
afterEach(function() {
sandbox.restore();
});
describe("standaloneRoomView", function() {
function mountTestComponent() {
return TestUtils.renderIntoDocument(
loop.standaloneRoomViews.StandaloneRoomView({
dispatcher: dispatcher,
activeRoomStore: activeRoomStore
}));
}
describe("#componentWillUpdate", function() {
it("dispatch an `SetupStreamElements` action on room joined", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
sinon.assert.notCalled(dispatch);
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch,
sinon.match.instanceOf(sharedActions.SetupStreamElements));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getLocalElementFunc() ===
view.getDOMNode().querySelector(".local");
}));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getRemoteElementFunc() ===
view.getDOMNode().querySelector(".remote");
}));
});
});
describe("#publishStream", function() {
var view;
beforeEach(function() {
view = mountTestComponent();
view.setState({
audioMuted: true,
videoMuted: true
});
});
it("should mute local audio stream", function() {
TestUtils.Simulate.click(
view.getDOMNode().querySelector(".btn-mute-audio"));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.SetMute({
type: "audio",
enabled: true
}));
});
it("should mute local video stream", function() {
TestUtils.Simulate.click(
view.getDOMNode().querySelector(".btn-mute-video"));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.SetMute({
type: "video",
enabled: true
}));
});
});
describe("#render", function() {
var view;
beforeEach(function() {
view = mountTestComponent();
});
describe("Join button", function() {
function getJoinButton(view) {
return view.getDOMNode().querySelector(".btn-join");
}
it("should render the Join button when room isn't active", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
expect(getJoinButton(view)).not.eql(null);
});
it("should not render the Join button when room is active",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
expect(getJoinButton(view)).eql(null);
});
it("should join the room when clicking the Join button", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
TestUtils.Simulate.click(getJoinButton(view));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.JoinRoom());
});
});
describe("Leave button", function() {
function getLeaveButton(view) {
return view.getDOMNode().querySelector(".btn-hangup");
}
it("should disable the Leave button when the room state is READY",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
expect(getLeaveButton(view).disabled).eql(true);
});
it("should disable the Leave button when the room state is FAILED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(getLeaveButton(view).disabled).eql(true);
});
it("should enable the Leave button when the room state is SESSION_CONNECTED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.SESSION_CONNECTED});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should enable the Leave button when the room state is JOINED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should enable the Leave button when the room state is HAS_PARTICIPANTS",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
expect(getLeaveButton(view).disabled).eql(false);
});
it("should leave the room when clicking the Leave button", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
TestUtils.Simulate.click(getLeaveButton(view));
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch, new sharedActions.LeaveRoom());
});
});
});
});
});

View File

@ -48,6 +48,7 @@
<script src="../content/js/conversationViews.js"></script>
<script src="../content/js/client.js"></script>
<script src="../content/js/webapp.js"></script>
<script src="../content/js/standaloneRoomViews.js"></script>
<script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
<script>
if (!loop.contacts) {

View File

@ -163,6 +163,11 @@
background: none;
}
/* Rooms edge cases */
.standalone .room-conversation .remote_wrapper {
background: none;
}
/* SVG icons showcase */
.svg-icon-entry {

View File

@ -32,6 +32,7 @@
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StandaloneRoomView = loop.standaloneRoomViews.StandaloneRoomView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
@ -558,6 +559,26 @@
)
),
Section({name: "StandaloneRoomView"},
Example({summary: "Standalone room conversation (ready)"},
React.DOM.div({className: "standalone"},
StandaloneRoomView({
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.READY})
)
),
Example({summary: "Standalone room conversation (has-participants)"},
React.DOM.div({className: "standalone"},
StandaloneRoomView({
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.HAS_PARTICIPANTS})
)
)
),
Section({name: "SVG icons preview"},
Example({summary: "16x16"},
SVGIcons(null)

View File

@ -32,6 +32,7 @@
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StandaloneRoomView = loop.standaloneRoomViews.StandaloneRoomView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
@ -558,6 +559,26 @@
</Example>
</Section>
<Section name="StandaloneRoomView">
<Example summary="Standalone room conversation (ready)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.READY} />
</div>
</Example>
<Example summary="Standalone room conversation (has-participants)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.HAS_PARTICIPANTS} />
</div>
</Example>
</Section>
<Section name="SVG icons preview">
<Example summary="16x16">
<SVGIcons />