Bug 1088672 - Part 3. Rewrite Loop's incoming call handling in the flux style. Get the accept and cancel buttons working again on the accept call view. r=mikedeboer

This commit is contained in:
Mark Banner 2015-03-12 14:01:38 +00:00
parent f912b459b6
commit 06571c17df
6 changed files with 247 additions and 115 deletions

View File

@ -141,7 +141,7 @@ loop.conversationViews = (function(mozL10n) {
var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
var AcceptCallView = React.createClass({displayName: "AcceptCallView",
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
callType: React.PropTypes.string.isRequired,
@ -166,17 +166,23 @@ loop.conversationViews = (function(mozL10n) {
_handleAccept: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.props.model.trigger("accept");
this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
callType: callType
}));
}.bind(this);
},
_handleDecline: function() {
this.props.model.trigger("decline");
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
blockCaller: false
}));
},
_handleDeclineBlock: function(e) {
this.props.model.trigger("declineAndBlock");
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
blockCaller: true
}));
/* Prevent event propagation
* stop the click from reaching parent element */
return false;
@ -189,12 +195,12 @@ loop.conversationViews = (function(mozL10n) {
**/
_answerModeProps: function() {
var videoButton = {
handler: this._handleAccept("audio-video"),
handler: this._handleAccept(CALL_TYPES.AUDIO_VIDEO),
className: "fx-embedded-btn-icon-video",
tooltip: "incoming_call_accept_audio_video_tooltip"
};
var audioButton = {
handler: this._handleAccept("audio"),
handler: this._handleAccept(CALL_TYPES.AUDIO_ONLY),
className: "fx-embedded-btn-audio-small",
tooltip: "incoming_call_accept_audio_only_tooltip"
};
@ -203,7 +209,7 @@ loop.conversationViews = (function(mozL10n) {
props.secondary = audioButton;
// When video is not enabled on this call, we swap the buttons around.
if (!this.props.video) {
if (this.props.callType === CALL_TYPES.AUDIO_ONLY) {
audioButton.className = "fx-embedded-btn-icon-audio";
videoButton.className = "fx-embedded-btn-video-small";
props.primary = audioButton;

View File

@ -141,7 +141,7 @@ loop.conversationViews = (function(mozL10n) {
var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
var AcceptCallView = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
callType: React.PropTypes.string.isRequired,
@ -166,17 +166,23 @@ loop.conversationViews = (function(mozL10n) {
_handleAccept: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.props.model.trigger("accept");
this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
callType: callType
}));
}.bind(this);
},
_handleDecline: function() {
this.props.model.trigger("decline");
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
blockCaller: false
}));
},
_handleDeclineBlock: function(e) {
this.props.model.trigger("declineAndBlock");
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
blockCaller: true
}));
/* Prevent event propagation
* stop the click from reaching parent element */
return false;
@ -189,12 +195,12 @@ loop.conversationViews = (function(mozL10n) {
**/
_answerModeProps: function() {
var videoButton = {
handler: this._handleAccept("audio-video"),
handler: this._handleAccept(CALL_TYPES.AUDIO_VIDEO),
className: "fx-embedded-btn-icon-video",
tooltip: "incoming_call_accept_audio_video_tooltip"
};
var audioButton = {
handler: this._handleAccept("audio"),
handler: this._handleAccept(CALL_TYPES.AUDIO_ONLY),
className: "fx-embedded-btn-audio-small",
tooltip: "incoming_call_accept_audio_only_tooltip"
};
@ -203,7 +209,7 @@ loop.conversationViews = (function(mozL10n) {
props.secondary = audioButton;
// When video is not enabled on this call, we swap the buttons around.
if (!this.props.video) {
if (this.props.callType === CALL_TYPES.AUDIO_ONLY) {
audioButton.className = "fx-embedded-btn-icon-audio";
videoButton.className = "fx-embedded-btn-video-small";
props.primary = audioButton;

View File

@ -96,6 +96,20 @@ loop.shared.actions = (function() {
RetryCall: Action.define("retryCall", {
}),
/**
* Signals when the user wishes to accept a call.
*/
AcceptCall: Action.define("acceptCall", {
callType: String
}),
/**
* Signals when the user declines a call.
*/
DeclineCall: Action.define("declineCall", {
blockCaller: Boolean
}),
/**
* Used to initiate connecting of a call with the relevant
* sessionData.

View File

@ -221,6 +221,8 @@ loop.store = loop.store || {};
this.dispatcher.register(this, [
"connectionFailure",
"connectionProgress",
"acceptCall",
"declineCall",
"connectCall",
"hangupCall",
"remotePeerDisconnected",
@ -255,6 +257,50 @@ loop.store = loop.store || {};
}
},
/**
* Accepts an incoming call.
*
* @param {sharedActions.AcceptCall} actionData
*/
acceptCall: function(actionData) {
if (this.getStoreState("outgoing")) {
console.error("Received AcceptCall action in outgoing call state");
return;
}
this.setStoreState({
callType: actionData.callType,
videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY
});
// Accepting the call on the websocket will bring us into the connecting
// state.
this._websocket.accept();
},
/**
* Declines an incoming call.
*
* @param {sharedActions.DeclineCall} actionData
*/
declineCall: function(actionData) {
if (actionData.blockCaller) {
this.mozLoop.calls.blockDirectCaller(this.getStoreState("callerId"),
function(err) {
// XXX The conversation window will be closed when this cb is triggered
// figure out if there is a better way to report the error to the user
// (bug 1103150).
console.log(err.fileName + ":" + err.lineNumber + ": " + err.message);
});
}
this._websocket.decline();
// Now we've declined, end the session and close the window.
this._endSession();
this.setStoreState({callState: CALL_STATES.CLOSE});
},
/**
* Handles the connect call action, this saves the appropriate
* data and starts the connection for the websocket to notify the

View File

@ -12,6 +12,7 @@ describe("loop.conversationViews", function () {
var fakeMozLoop, fakeWindow;
var CALL_STATES = loop.store.CALL_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
@ -1221,37 +1222,25 @@ describe("loop.conversationViews", function () {
});
describe("AcceptCallView", function() {
var view, model, fakeAudio;
var view;
beforeEach(function() {
var Model = Backbone.Model.extend({
getCallIdentifier: function() {return "fakeId";}
});
model = new Model();
sandbox.spy(model, "trigger");
sandbox.stub(model, "set");
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, props));
}
fakeAudio = {
play: sinon.spy(),
pause: sinon.spy(),
removeAttribute: sinon.spy()
};
sandbox.stub(window, "Audio").returns(fakeAudio);
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: true
}));
afterEach(function() {
view = null;
});
describe("default answer mode", function() {
it("should display video as primary answer mode", function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: true
}));
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var primaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-icon-video');
@ -1259,11 +1248,12 @@ describe("loop.conversationViews", function () {
});
it("should display audio as primary answer mode", function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: false
}));
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_ONLY,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var primaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-icon-audio');
@ -1271,116 +1261,117 @@ describe("loop.conversationViews", function () {
});
it("should accept call with video", function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: true
}));
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var primaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-icon-video');
React.addons.TestUtils.Simulate.click(primaryBtn);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWithExactly(model.trigger, "accept");
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AcceptCall({
callType: CALL_TYPES.AUDIO_VIDEO
}));
});
it("should accept call with audio", function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: false
}));
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_ONLY,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var primaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-icon-audio');
React.addons.TestUtils.Simulate.click(primaryBtn);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWithExactly(model.trigger, "accept");
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AcceptCall({
callType: CALL_TYPES.AUDIO_ONLY
}));
});
it("should accept call with video when clicking on secondary btn",
function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: false
}));
function() {
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_ONLY,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var secondaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-video-small');
React.addons.TestUtils.Simulate.click(secondaryBtn);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWithExactly(model.trigger, "accept");
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AcceptCall({
callType: CALL_TYPES.AUDIO_VIDEO
}));
});
it("should accept call with audio when clicking on secondary btn",
function() {
view = TestUtils.renderIntoDocument(
React.createElement(loop.conversationViews.AcceptCallView, {
model: model,
video: true
}));
function() {
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var secondaryBtn = view.getDOMNode()
.querySelector('.fx-embedded-btn-audio-small');
React.addons.TestUtils.Simulate.click(secondaryBtn);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWithExactly(model.trigger, "accept");
});
});
describe("click event on .btn-accept", function() {
it("should trigger an 'accept' conversation model event", function () {
var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
model.trigger.withArgs("accept");
TestUtils.Simulate.click(buttonAccept);
/* Setting a model property triggers 2 events */
sinon.assert.calledOnce(model.trigger.withArgs("accept"));
});
it("should set selectedCallType to audio-video", function () {
var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
TestUtils.Simulate.click(buttonAccept);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType",
"audio-video");
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.AcceptCall({
callType: CALL_TYPES.AUDIO_ONLY
}));
});
});
describe("click event on .btn-decline", function() {
it("should trigger an 'decline' conversation model event", function() {
it("should dispatch a DeclineCall action", function() {
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var buttonDecline = view.getDOMNode().querySelector(".btn-decline");
TestUtils.Simulate.click(buttonDecline);
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWith(model.trigger, "decline");
});
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.DeclineCall({blockCaller: false}));
});
});
describe("click event on .btn-block", function() {
it("should trigger a 'block' conversation model event", function() {
it("should dispatch a DeclineCall action with blockCaller true", function() {
view = mountTestComponent({
callType: CALL_TYPES.AUDIO_VIDEO,
callerId: "fake@invalid.com",
dispatcher: dispatcher
});
var buttonBlock = view.getDOMNode().querySelector(".btn-block");
TestUtils.Simulate.click(buttonBlock);
sinon.assert.calledOnce(model.trigger);
sinon.assert.calledWith(model.trigger, "declineAndBlock");
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.DeclineCall({blockCaller: true}));
});
});
});

View File

@ -8,6 +8,7 @@ describe("loop.store.ConversationStore", function () {
var CALL_STATES = loop.store.CALL_STATES;
var WS_STATES = loop.store.WS_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var sharedActions = loop.shared.actions;
@ -43,7 +44,8 @@ describe("loop.store.ConversationStore", function () {
addConversationContext: sandbox.stub(),
calls: {
setCallInProgress: sandbox.stub(),
clearCallInProgress: sandbox.stub()
clearCallInProgress: sandbox.stub(),
blockDirectCaller: sandbox.stub()
},
rooms: {
create: sandbox.stub()
@ -495,6 +497,73 @@ describe("loop.store.ConversationStore", function () {
});
});
describe("#acceptCall", function() {
beforeEach(function() {
store._websocket = {
accept: sinon.stub()
};
});
it("should save the call type", function() {
store.acceptCall(
new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
expect(store.getStoreState("callType")).eql(CALL_TYPES.AUDIO_ONLY);
expect(store.getStoreState("videoMuted")).eql(true);
});
it("should call accept on the websocket", function() {
store.acceptCall(
new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
sinon.assert.calledOnce(store._websocket.accept);
});
});
describe("#declineCall", function() {
var fakeWebsocket;
beforeEach(function() {
fakeWebsocket = store._websocket = {
decline: sinon.stub(),
close: sinon.stub()
};
store.setStoreState({windowId: 42});
});
it("should block the caller if necessary", function() {
store.declineCall(new sharedActions.DeclineCall({blockCaller: true}));
sinon.assert.calledOnce(fakeMozLoop.calls.blockDirectCaller);
});
it("should call decline on the websocket", function() {
store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
sinon.assert.calledOnce(fakeWebsocket.decline);
});
it("should close the websocket", function() {
store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
sinon.assert.calledOnce(fakeWebsocket.close);
});
it("should clear the call in progress for the backend", function() {
store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
sinon.assert.calledOnce(fakeMozLoop.calls.clearCallInProgress);
sinon.assert.calledWithExactly(fakeMozLoop.calls.clearCallInProgress, 42);
});
it("should set the call state to CLOSE", function() {
store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
expect(store.getStoreState("callState")).eql(CALL_STATES.CLOSE);
});
});
describe("#connectCall", function() {
it("should save the call session data", function() {
store.connectCall(