diff --git a/browser/components/loop/content/shared/js/models.js b/browser/components/loop/content/shared/js/models.js index b7346c62f1e4..5cbdfb024302 100644 --- a/browser/components/loop/content/shared/js/models.js +++ b/browser/components/loop/content/shared/js/models.js @@ -95,6 +95,13 @@ loop.shared.models = (function(l10n) { if (selectedCallType) { this.set("selectedCallType", selectedCallType); } + this.trigger("call:outgoing:get-media-privs"); + }, + + /** + * Used to indicate that media privileges have been accepted. + */ + gotMediaPrivs: function() { this.trigger("call:outgoing:setup"); }, diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js index e7da48238a86..df7a9f2de5c0 100644 --- a/browser/components/loop/standalone/content/js/webapp.js +++ b/browser/components/loop/standalone/content/js/webapp.js @@ -270,7 +270,80 @@ loop.webapp = (function($, _, OT, mozL10n) { } }); + /** + * A view for when conversations are pending, displays any messages + * and an option cancel button. + */ var PendingConversationView = React.createClass({displayName: 'PendingConversationView', + propTypes: { + callState: React.PropTypes.string.isRequired, + // If not supplied, the cancel button is not displayed. + cancelCallback: React.PropTypes.func + }, + + render: function() { + var cancelButtonClasses = React.addons.classSet({ + btn: true, + "btn-large": true, + "btn-cancel": true, + hide: !this.props.cancelCallback + }); + + return ( + React.DOM.div({className: "container"}, + React.DOM.div({className: "container-box"}, + React.DOM.header({className: "pending-header header-box"}, + ConversationBranding(null) + ), + + React.DOM.div({id: "cameraPreview"}), + + React.DOM.div({id: "messages"}), + + React.DOM.p({className: "standalone-btn-label"}, + this.props.callState + ), + + React.DOM.div({className: "btn-pending-cancel-group btn-group"}, + React.DOM.div({className: "flex-padding-1"}), + React.DOM.button({className: cancelButtonClasses, + onClick: this.props.cancelCallback}, + React.DOM.span({className: "standalone-call-btn-text"}, + mozL10n.get("initiate_call_cancel_button") + ) + ), + React.DOM.div({className: "flex-padding-1"}) + ) + ), + ConversationFooter(null) + ) + ); + } + }); + + /** + * View displayed whilst the get user media prompt is being displayed. Indicates + * to the user to accept the prompt. + */ + var GumPromptConversationView = React.createClass({displayName: 'GumPromptConversationView', + render: function() { + var callState = mozL10n.get("call_progress_getting_media_description", { + clientShortname: mozL10n.get("clientShortname2") + }); + document.title = mozL10n.get("standalone_title_with_status", { + clientShortname: mozL10n.get("clientShortname2"), + currentStatus: mozL10n.get("call_progress_getting_media_title") + }); + + return PendingConversationView({callState: callState}); + } + }); + + /** + * View displayed waiting for a call to be connected. Updates the display + * once the websocket shows that the callee is being alerted. + */ + var WaitingConversationView = React.createClass({displayName: 'WaitingConversationView', mixins: [sharedMixins.AudioMixin], getInitialState: function() { @@ -306,33 +379,11 @@ loop.webapp = (function($, _, OT, mozL10n) { document.title = mozL10n.get("standalone_title_with_status", {clientShortname: mozL10n.get("clientShortname2"), currentStatus: mozL10n.get(callStateStringEntityName)}); + return ( - React.DOM.div({className: "container"}, - React.DOM.div({className: "container-box"}, - React.DOM.header({className: "pending-header header-box"}, - ConversationBranding(null) - ), - - React.DOM.div({id: "cameraPreview"}), - - React.DOM.div({id: "messages"}), - - React.DOM.p({className: "standalone-btn-label"}, - callState - ), - - React.DOM.div({className: "btn-pending-cancel-group btn-group"}, - React.DOM.div({className: "flex-padding-1"}), - React.DOM.button({className: "btn btn-large btn-cancel", - onClick: this._cancelOutgoingCall}, - React.DOM.span({className: "standalone-call-btn-text"}, - mozL10n.get("initiate_call_cancel_button") - ) - ), - React.DOM.div({className: "flex-padding-1"}) - ) - ), - ConversationFooter(null) + PendingConversationView({ + callState: callState, + cancelCallback: this._cancelOutgoingCall} ) ); } @@ -458,15 +509,8 @@ loop.webapp = (function($, _, OT, mozL10n) { */ startCall: function(callType) { return function() { - multiplexGum.getPermsAndCacheMedia({audio:true, video:true}, - function(localStream) { - this.props.conversation.setupOutgoingCall(callType); - this.setState({disableCallButton: true}); - }.bind(this), - function(errorCode) { - multiplexGum.reset(); - }.bind(this) - ); + this.props.conversation.setupOutgoingCall(callType); + this.setState({disableCallButton: true}); }.bind(this); }, @@ -627,6 +671,7 @@ loop.webapp = (function($, _, OT, mozL10n) { componentDidMount: function() { this.props.conversation.on("call:outgoing", this.startCall, this); + this.props.conversation.on("call:outgoing:get-media-privs", this.getMediaPrivs, this); this.props.conversation.on("call:outgoing:setup", this.setupOutgoingCall, this); this.props.conversation.on("change:publishedStream", this._checkConnected, this); this.props.conversation.on("change:subscribedStream", this._checkConnected, this); @@ -674,8 +719,11 @@ loop.webapp = (function($, _, OT, mozL10n) { ) ); } + case "gumPrompt": { + return GumPromptConversationView(null); + } case "pending": { - return PendingConversationView({websocket: this._websocket}); + return WaitingConversationView({websocket: this._websocket}); } case "connected": { document.title = mozL10n.get("standalone_title_with_status", @@ -774,6 +822,22 @@ loop.webapp = (function($, _, OT, mozL10n) { } }, + /** + * Asks the user for the media privileges, handling the result appropriately. + */ + getMediaPrivs: function() { + this.setState({callStatus: "gumPrompt"}); + multiplexGum.getPermsAndCacheMedia({audio:true, video:true}, + function(localStream) { + this.props.conversation.gotMediaPrivs(); + }.bind(this), + function(errorCode) { + multiplexGum.reset(); + this.setState({callStatus: "failure"}); + }.bind(this) + ); + }, + /** * Actually starts the call. */ @@ -866,6 +930,8 @@ loop.webapp = (function($, _, OT, mozL10n) { * Handles ending a call by resetting the view to the start state. */ _endCall: function() { + multiplexGum.reset(); + if (this.state.callStatus !== "failure") { this.setState({callStatus: "end"}); } @@ -1050,6 +1116,8 @@ loop.webapp = (function($, _, OT, mozL10n) { return { CallUrlExpiredView: CallUrlExpiredView, PendingConversationView: PendingConversationView, + GumPromptConversationView: GumPromptConversationView, + WaitingConversationView: WaitingConversationView, StartConversationView: StartConversationView, FailedConversationView: FailedConversationView, OutgoingConversationView: OutgoingConversationView, diff --git a/browser/components/loop/standalone/content/js/webapp.jsx b/browser/components/loop/standalone/content/js/webapp.jsx index 4756e49289ee..ac680b718f78 100644 --- a/browser/components/loop/standalone/content/js/webapp.jsx +++ b/browser/components/loop/standalone/content/js/webapp.jsx @@ -270,7 +270,80 @@ loop.webapp = (function($, _, OT, mozL10n) { } }); + /** + * A view for when conversations are pending, displays any messages + * and an option cancel button. + */ var PendingConversationView = React.createClass({ + propTypes: { + callState: React.PropTypes.string.isRequired, + // If not supplied, the cancel button is not displayed. + cancelCallback: React.PropTypes.func + }, + + render: function() { + var cancelButtonClasses = React.addons.classSet({ + btn: true, + "btn-large": true, + "btn-cancel": true, + hide: !this.props.cancelCallback + }); + + return ( +
+
+
+ +
+ +
+ +
+ +

+ {this.props.callState} +

+ +
+
+ +
+
+
+ +
+ ); + } + }); + + /** + * View displayed whilst the get user media prompt is being displayed. Indicates + * to the user to accept the prompt. + */ + var GumPromptConversationView = React.createClass({ + render: function() { + var callState = mozL10n.get("call_progress_getting_media_description", { + clientShortname: mozL10n.get("clientShortname2") + }); + document.title = mozL10n.get("standalone_title_with_status", { + clientShortname: mozL10n.get("clientShortname2"), + currentStatus: mozL10n.get("call_progress_getting_media_title") + }); + + return ; + } + }); + + /** + * View displayed waiting for a call to be connected. Updates the display + * once the websocket shows that the callee is being alerted. + */ + var WaitingConversationView = React.createClass({ mixins: [sharedMixins.AudioMixin], getInitialState: function() { @@ -306,34 +379,12 @@ loop.webapp = (function($, _, OT, mozL10n) { document.title = mozL10n.get("standalone_title_with_status", {clientShortname: mozL10n.get("clientShortname2"), currentStatus: mozL10n.get(callStateStringEntityName)}); + return ( -
-
-
- -
- -
- -
- -

- {callState} -

- -
-
- -
-
-
- -
+ ); } }); @@ -458,15 +509,8 @@ loop.webapp = (function($, _, OT, mozL10n) { */ startCall: function(callType) { return function() { - multiplexGum.getPermsAndCacheMedia({audio:true, video:true}, - function(localStream) { - this.props.conversation.setupOutgoingCall(callType); - this.setState({disableCallButton: true}); - }.bind(this), - function(errorCode) { - multiplexGum.reset(); - }.bind(this) - ); + this.props.conversation.setupOutgoingCall(callType); + this.setState({disableCallButton: true}); }.bind(this); }, @@ -627,6 +671,7 @@ loop.webapp = (function($, _, OT, mozL10n) { componentDidMount: function() { this.props.conversation.on("call:outgoing", this.startCall, this); + this.props.conversation.on("call:outgoing:get-media-privs", this.getMediaPrivs, this); this.props.conversation.on("call:outgoing:setup", this.setupOutgoingCall, this); this.props.conversation.on("change:publishedStream", this._checkConnected, this); this.props.conversation.on("change:subscribedStream", this._checkConnected, this); @@ -674,8 +719,11 @@ loop.webapp = (function($, _, OT, mozL10n) { /> ); } + case "gumPrompt": { + return ; + } case "pending": { - return ; + return ; } case "connected": { document.title = mozL10n.get("standalone_title_with_status", @@ -774,6 +822,22 @@ loop.webapp = (function($, _, OT, mozL10n) { } }, + /** + * Asks the user for the media privileges, handling the result appropriately. + */ + getMediaPrivs: function() { + this.setState({callStatus: "gumPrompt"}); + multiplexGum.getPermsAndCacheMedia({audio:true, video:true}, + function(localStream) { + this.props.conversation.gotMediaPrivs(); + }.bind(this), + function(errorCode) { + multiplexGum.reset(); + this.setState({callStatus: "failure"}); + }.bind(this) + ); + }, + /** * Actually starts the call. */ @@ -866,6 +930,8 @@ loop.webapp = (function($, _, OT, mozL10n) { * Handles ending a call by resetting the view to the start state. */ _endCall: function() { + multiplexGum.reset(); + if (this.state.callStatus !== "failure") { this.setState({callStatus: "end"}); } @@ -1050,6 +1116,8 @@ loop.webapp = (function($, _, OT, mozL10n) { return { CallUrlExpiredView: CallUrlExpiredView, PendingConversationView: PendingConversationView, + GumPromptConversationView: GumPromptConversationView, + WaitingConversationView: WaitingConversationView, StartConversationView: StartConversationView, FailedConversationView: FailedConversationView, OutgoingConversationView: OutgoingConversationView, diff --git a/browser/components/loop/standalone/content/l10n/en-US/loop.properties b/browser/components/loop/standalone/content/l10n/en-US/loop.properties index 47f98f1c6169..8861b0abb704 100644 --- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties +++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties @@ -58,6 +58,8 @@ vendor_alttext={{vendorShortname}} logo ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014) call_url_creation_date_label=(from {{call_url_creation_date}}) +call_progress_getting_media_description={{clientShortname}} requires access to your camera and microphone. +call_progress_getting_media_title=Waiting for media… call_progress_connecting_description=Connecting… call_progress_ringing_description=Ringing… fxos_app_needed=Please install the {{fxosAppName}} app from the Firefox Marketplace. diff --git a/browser/components/loop/test/shared/models_test.js b/browser/components/loop/test/shared/models_test.js index 8936ed24715f..37c33838d1a2 100644 --- a/browser/components/loop/test/shared/models_test.js +++ b/browser/components/loop/test/shared/models_test.js @@ -91,12 +91,22 @@ describe("loop.shared.models", function() { expect(conversation.get("selectedCallType")).eql("audio-video"); }); + it("should trigger a `call:outgoing:get-media-privs` event", function(done) { + conversation.once("call:outgoing:get-media-privs", function() { + done(); + }); + + conversation.setupOutgoingCall(); + }); + }); + + describe("#gotMediaPrivs", function() { it("should trigger a `call:outgoing:setup` event", function(done) { conversation.once("call:outgoing:setup", function() { done(); }); - conversation.setupOutgoingCall(); + conversation.gotMediaPrivs(); }); }); diff --git a/browser/components/loop/test/standalone/webapp_test.js b/browser/components/loop/test/standalone/webapp_test.js index 2547376b9f5e..3a1681cdead7 100644 --- a/browser/components/loop/test/standalone/webapp_test.js +++ b/browser/components/loop/test/standalone/webapp_test.js @@ -369,6 +369,16 @@ describe("loop.webapp", function() { }); describe("session:ended", function() { + it("should call multiplexGum.reset", function() { + var multiplexGum = new standaloneMedia._MultiplexGum(); + standaloneMedia.setSingleton(multiplexGum); + sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset"); + + conversation.trigger("session:ended"); + + sinon.assert.calledOnce(multiplexGum.reset); + }); + it("should display the StartConversationView", function() { conversation.trigger("session:ended"); @@ -474,14 +484,14 @@ describe("loop.webapp", function() { }); it("should display the FailedConversationView", function() { - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); TestUtils.findRenderedComponentWithType(ocView, loop.webapp.FailedConversationView); }); it("should display an error", function() { - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); sinon.assert.calledOnce(notifications.errorL10n); }); @@ -494,7 +504,8 @@ describe("loop.webapp", function() { it("should call requestCallInfo on the client", function() { - conversation.setupOutgoingCall("audio-video"); + conversation.set("selectedCallType", "audio-video"); + ocView.setupOutgoingCall(); sinon.assert.calledOnce(client.requestCallInfo); sinon.assert.calledWith(client.requestCallInfo, "fakeToken", @@ -506,7 +517,7 @@ describe("loop.webapp", function() { function() { client.requestCallInfo.callsArgWith(2, {errno: 105}); - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); TestUtils.findRenderedComponentWithType(ocView, loop.webapp.CallUrlExpiredView); @@ -516,7 +527,7 @@ describe("loop.webapp", function() { function() { client.requestCallInfo.callsArgWith(2, {errno: 104}); - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); TestUtils.findRenderedComponentWithType(ocView, loop.webapp.FailedConversationView); @@ -525,7 +536,7 @@ describe("loop.webapp", function() { it("should notify the user on any other error", function() { client.requestCallInfo.callsArgWith(2, {errno: 104}); - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); sinon.assert.calledOnce(notifications.errorL10n); }); @@ -534,7 +545,7 @@ describe("loop.webapp", function() { "are successfully received", function() { client.requestCallInfo.callsArgWith(2, null, fakeSessionData); - conversation.setupOutgoingCall(); + ocView.setupOutgoingCall(); sinon.assert.calledOnce(conversation.outgoing); sinon.assert.calledWithExactly(conversation.outgoing, fakeSessionData); @@ -542,6 +553,52 @@ describe("loop.webapp", function() { }); }); }); + + describe("getMediaPrivs", function() { + var multiplexGum; + + beforeEach(function() { + multiplexGum = new standaloneMedia._MultiplexGum(); + standaloneMedia.setSingleton(multiplexGum); + sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset"); + + sandbox.stub(conversation, "gotMediaPrivs"); + }); + + it("should call getPermsAndCacheMedia", function() { + conversation.trigger("call:outgoing:get-media-privs"); + + sinon.assert.calledOnce(stubGetPermsAndCacheMedia); + }); + + it("should call gotMediaPrevs on the model when successful", function() { + stubGetPermsAndCacheMedia.callsArgWith(1, {}); + + conversation.trigger("call:outgoing:get-media-privs"); + + sinon.assert.calledOnce(conversation.gotMediaPrivs); + }); + + it("should call multiplexGum.reset when getPermsAndCacheMedia fails", + function() { + stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR"); + + conversation.trigger("call:outgoing:get-media-privs"); + + sinon.assert.calledOnce(multiplexGum.reset); + }); + + it("should set state to `failure` when getPermsAndCacheMedia fails", + function() { + stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR"); + + conversation.trigger("call:outgoing:get-media-privs"); + + expect(ocView.state.callStatus).eql("failure"); + }); + }); + + }); describe("FailedConversationView", function() { @@ -693,7 +750,7 @@ describe("loop.webapp", function() { }); }); - describe("PendingConversationView", function() { + describe("WaitingConversationView", function() { var view, websocket, fakeAudio; beforeEach(function() { @@ -713,7 +770,7 @@ describe("loop.webapp", function() { sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR); view = React.addons.TestUtils.renderIntoDocument( - loop.webapp.PendingConversationView({ + loop.webapp.WaitingConversationView({ websocket: websocket }) ); @@ -802,27 +859,8 @@ describe("loop.webapp", function() { client: standaloneClientStub }) ); - - // default to succeeding with a null local media object - stubGetPermsAndCacheMedia.callsArgWith(1, {}); }); - it("should fire multiplexGum.reset when getPermsAndCacheMedia calls" + - " back an error", - function() { - var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall"); - var multiplexGum = new standaloneMedia._MultiplexGum(); - standaloneMedia.setSingleton(multiplexGum); - sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset"); - stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR"); - - var button = view.getDOMNode().querySelector(".btn-accept"); - React.addons.TestUtils.Simulate.click(button); - - sinon.assert.calledOnce(multiplexGum.reset); - sinon.assert.calledWithExactly(multiplexGum.reset); - }); - it("should start the audio-video conversation establishment process", function() { var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall"); diff --git a/browser/components/loop/ui/ui-showcase.js b/browser/components/loop/ui/ui-showcase.js index af4373a29349..df7098a76659 100644 --- a/browser/components/loop/ui/ui-showcase.js +++ b/browser/components/loop/ui/ui-showcase.js @@ -28,7 +28,8 @@ var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView; var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView; var CallUrlExpiredView = loop.webapp.CallUrlExpiredView; - var PendingConversationView = loop.webapp.PendingConversationView; + var GumPromptConversationView = loop.webapp.GumPromptConversationView; + var WaitingConversationView = loop.webapp.WaitingConversationView; var StartConversationView = loop.webapp.StartConversationView; var FailedConversationView = loop.webapp.FailedConversationView; var EndedConversationView = loop.webapp.EndedConversationView; @@ -326,16 +327,24 @@ ) ), - Section({name: "PendingConversationView"}, - Example({summary: "Pending conversation view (connecting)", dashed: "true"}, + Section({name: "GumPromptConversationView"}, + Example({summary: "Gum Prompt conversation view", dashed: "true"}, React.DOM.div({className: "standalone"}, - PendingConversationView({websocket: mockWebSocket, + GumPromptConversationView(null) + ) + ) + ), + + Section({name: "WaitingConversationView"}, + Example({summary: "Waiting conversation view (connecting)", dashed: "true"}, + React.DOM.div({className: "standalone"}, + WaitingConversationView({websocket: mockWebSocket, dispatcher: dispatcher}) ) ), - Example({summary: "Pending conversation view (ringing)", dashed: "true"}, + Example({summary: "Waiting conversation view (ringing)", dashed: "true"}, React.DOM.div({className: "standalone"}, - PendingConversationView({websocket: mockWebSocket, + WaitingConversationView({websocket: mockWebSocket, dispatcher: dispatcher, callState: "ringing"}) ) diff --git a/browser/components/loop/ui/ui-showcase.jsx b/browser/components/loop/ui/ui-showcase.jsx index f5514b21a683..0a2693003cab 100644 --- a/browser/components/loop/ui/ui-showcase.jsx +++ b/browser/components/loop/ui/ui-showcase.jsx @@ -28,7 +28,8 @@ var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView; var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView; var CallUrlExpiredView = loop.webapp.CallUrlExpiredView; - var PendingConversationView = loop.webapp.PendingConversationView; + var GumPromptConversationView = loop.webapp.GumPromptConversationView; + var WaitingConversationView = loop.webapp.WaitingConversationView; var StartConversationView = loop.webapp.StartConversationView; var FailedConversationView = loop.webapp.FailedConversationView; var EndedConversationView = loop.webapp.EndedConversationView; @@ -326,16 +327,24 @@
-
- +
+
- +
+
+
+ +
+ +
+
- +
-