mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
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:
parent
f912b459b6
commit
06571c17df
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user