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 @@
-