Bug 1093787 - Insert an additional view for Loop standalone calls to prompt the user to accept the microphone and camera permissions before starting the call. r=nperriault

This commit is contained in:
Mark Banner 2014-11-25 14:31:43 +00:00
parent b1d71f50ed
commit ee077adad2
8 changed files with 325 additions and 114 deletions

View File

@ -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");
},

View File

@ -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)
);
}.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,

View File

@ -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 (
<div className="container">
<div className="container-box">
<header className="pending-header header-box">
<ConversationBranding />
</header>
<div id="cameraPreview" />
<div id="messages" />
<p className="standalone-btn-label">
{this.props.callState}
</p>
<div className="btn-pending-cancel-group btn-group">
<div className="flex-padding-1" />
<button className={cancelButtonClasses}
onClick={this.props.cancelCallback} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_call_cancel_button")}
</span>
</button>
<div className="flex-padding-1" />
</div>
</div>
<ConversationFooter />
</div>
);
}
});
/**
* 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 <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({
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 (
<div className="container">
<div className="container-box">
<header className="pending-header header-box">
<ConversationBranding />
</header>
<div id="cameraPreview" />
<div id="messages" />
<p className="standalone-btn-label">
{callState}
</p>
<div className="btn-pending-cancel-group btn-group">
<div className="flex-padding-1" />
<button className="btn btn-large btn-cancel"
onClick={this._cancelOutgoingCall} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_call_cancel_button")}
</span>
</button>
<div className="flex-padding-1" />
</div>
</div>
<ConversationFooter />
</div>
<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)
);
}.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 />;
}
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,

View File

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

View File

@ -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();
});
});

View File

@ -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,25 +859,6 @@ 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",

View File

@ -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"})
)

View File

@ -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 @@
</div>
</Section>
<Section name="PendingConversationView">
<Example summary="Pending conversation view (connecting)" dashed="true">
<Section name="GumPromptConversationView">
<Example summary="Gum Prompt conversation view" dashed="true">
<div className="standalone">
<PendingConversationView websocket={mockWebSocket}
<GumPromptConversationView />
</div>
</Example>
</Section>
<Section name="WaitingConversationView">
<Example summary="Waiting conversation view (connecting)" dashed="true">
<div className="standalone">
<WaitingConversationView websocket={mockWebSocket}
dispatcher={dispatcher} />
</div>
</Example>
<Example summary="Pending conversation view (ringing)" dashed="true">
<Example summary="Waiting conversation view (ringing)" dashed="true">
<div className="standalone">
<PendingConversationView websocket={mockWebSocket}
<WaitingConversationView websocket={mockWebSocket}
dispatcher={dispatcher}
callState="ringing"/>
</div>