mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
Bug 1212083 - Part 2. Remove the unused files previously associated with direct calls. r=mikedeboer
This commit is contained in:
parent
24b18f5c05
commit
503a193e66
@ -1,119 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.Client = (function() {
|
||||
"use strict";
|
||||
|
||||
// THe expected properties to be returned from the POST /calls request.
|
||||
var expectedPostCallProperties = [
|
||||
"apiKey", "callId", "progressURL",
|
||||
"sessionId", "sessionToken", "websocketToken"
|
||||
];
|
||||
|
||||
/**
|
||||
* Loop server client.
|
||||
*
|
||||
* @param {Object} settings Settings object.
|
||||
*/
|
||||
function Client(settings) {
|
||||
if (!settings) {
|
||||
settings = {};
|
||||
}
|
||||
// allowing an |in| test rather than a more type || allows us to dependency
|
||||
// inject a non-existent mozLoop
|
||||
if ("mozLoop" in settings) {
|
||||
this.mozLoop = settings.mozLoop;
|
||||
} else {
|
||||
this.mozLoop = navigator.mozLoop;
|
||||
}
|
||||
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
Client.prototype = {
|
||||
/**
|
||||
* Validates a data object to confirm it has the specified properties.
|
||||
*
|
||||
* @param {Object} The data object to verify
|
||||
* @param {Array} The list of properties to verify within the object
|
||||
* @return This returns either the specific property if only one
|
||||
* property is specified, or it returns all properties
|
||||
*/
|
||||
_validate: function(data, properties) {
|
||||
if (typeof data !== "object") {
|
||||
throw new Error("Invalid data received from server");
|
||||
}
|
||||
|
||||
properties.forEach(function (property) {
|
||||
if (!data.hasOwnProperty(property)) {
|
||||
throw new Error("Invalid data received from server - missing " +
|
||||
property);
|
||||
}
|
||||
});
|
||||
|
||||
if (properties.length === 1) {
|
||||
return data[properties[0]];
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generic handler for XHR failures.
|
||||
*
|
||||
* @param {Function} cb Callback(err)
|
||||
* @param {Object} error See MozLoopAPI.hawkRequest
|
||||
*/
|
||||
_failureHandler: function(cb, error) {
|
||||
var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
|
||||
console.error(message);
|
||||
cb(error);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets up an outgoing call, getting the relevant data from the server.
|
||||
*
|
||||
* Callback parameters:
|
||||
* - err null on successful registration, non-null otherwise.
|
||||
* - result an object of the obtained data for starting the call, if successful
|
||||
*
|
||||
* @param {Array} calleeIds an array of emails and phone numbers.
|
||||
* @param {String} callType the type of call.
|
||||
* @param {Function} cb Callback(err, result)
|
||||
*/
|
||||
setupOutgoingCall: function(calleeIds, callType, cb) {
|
||||
// For direct calls, we only ever use the logged-in session. Direct
|
||||
// calls by guests aren't valid.
|
||||
this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA,
|
||||
"/calls", "POST", {
|
||||
calleeId: calleeIds,
|
||||
callType: callType,
|
||||
channel: this.mozLoop.appVersionInfo ?
|
||||
this.mozLoop.appVersionInfo.channel : "unknown"
|
||||
},
|
||||
function (err, responseText) {
|
||||
if (err) {
|
||||
this._failureHandler(cb, err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var postData = JSON.parse(responseText);
|
||||
|
||||
var outgoingCallData = this._validate(postData,
|
||||
expectedPostCallProperties);
|
||||
|
||||
cb(null, outgoingCallData);
|
||||
} catch (ex) {
|
||||
console.log("Error requesting call info", ex);
|
||||
cb(ex);
|
||||
}
|
||||
}.bind(this)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return Client;
|
||||
})();
|
@ -1,805 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.conversationViews = (function(mozL10n) {
|
||||
"use strict";
|
||||
|
||||
var CALL_STATES = loop.store.CALL_STATES;
|
||||
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
|
||||
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
var sharedActions = loop.shared.actions;
|
||||
var sharedUtils = loop.shared.utils;
|
||||
var sharedViews = loop.shared.views;
|
||||
var sharedMixins = loop.shared.mixins;
|
||||
|
||||
// This duplicates a similar function in contacts.jsx that isn't used in the
|
||||
// conversation window. If we get too many of these, we might want to consider
|
||||
// finding a logical place for them to be shared.
|
||||
|
||||
// XXXdmose this code is already out of sync with the code in contacts.jsx
|
||||
// which, unlike this code, now has unit tests. We should totally do the
|
||||
// above.
|
||||
|
||||
function _getPreferredEmail(contact) {
|
||||
// A contact may not contain email addresses, but only a phone number.
|
||||
if (!contact.email || contact.email.length === 0) {
|
||||
return { value: "" };
|
||||
}
|
||||
return contact.email.find(function find(e) { return e.pref; }) || contact.email[0];
|
||||
}
|
||||
|
||||
function _getContactDisplayName(contact) {
|
||||
if (contact.name && contact.name[0]) {
|
||||
return contact.name[0];
|
||||
}
|
||||
return _getPreferredEmail(contact).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays information about the call
|
||||
* Caller avatar, name & conversation creation date
|
||||
*/
|
||||
var CallIdentifierView = React.createClass({displayName: "CallIdentifierView",
|
||||
propTypes: {
|
||||
peerIdentifier: React.PropTypes.string,
|
||||
showIcons: React.PropTypes.bool.isRequired,
|
||||
urlCreationDate: React.PropTypes.string,
|
||||
video: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
peerIdentifier: "",
|
||||
showLinkDetail: true,
|
||||
urlCreationDate: "",
|
||||
video: true
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {timestamp: 0};
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets and formats the incoming call creation date
|
||||
*/
|
||||
formatCreationDate: function() {
|
||||
if (!this.props.urlCreationDate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
var timestamp = this.props.urlCreationDate;
|
||||
return "(" + loop.shared.utils.formatDate(timestamp) + ")";
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var iconVideoClasses = React.addons.classSet({
|
||||
"fx-embedded-tiny-video-icon": true,
|
||||
"muted": !this.props.video
|
||||
});
|
||||
var callDetailClasses = React.addons.classSet({
|
||||
"fx-embedded-call-detail": true,
|
||||
"hide": !this.props.showIcons
|
||||
});
|
||||
|
||||
return (
|
||||
React.createElement("div", {className: "fx-embedded-call-identifier"},
|
||||
React.createElement("div", {className: "fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"}),
|
||||
React.createElement("div", {className: "fx-embedded-call-identifier-info fx-embedded-call-identifier-item"},
|
||||
React.createElement("div", {className: "fx-embedded-call-identifier-text overflow-text-ellipsis"},
|
||||
this.props.peerIdentifier
|
||||
),
|
||||
React.createElement("div", {className: callDetailClasses},
|
||||
React.createElement("span", {className: "fx-embedded-tiny-audio-icon"}),
|
||||
React.createElement("span", {className: iconVideoClasses}),
|
||||
React.createElement("span", {className: "fx-embedded-conversation-timestamp"},
|
||||
this.formatCreationDate()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Displays details of the incoming/outgoing conversation
|
||||
* (name, link, audio/video type etc).
|
||||
*
|
||||
* Allows the view to be extended with different buttons and progress
|
||||
* via children properties.
|
||||
*/
|
||||
var ConversationDetailView = React.createClass({displayName: "ConversationDetailView",
|
||||
propTypes: {
|
||||
children: React.PropTypes.oneOfType([
|
||||
React.PropTypes.element,
|
||||
React.PropTypes.arrayOf(React.PropTypes.element)
|
||||
]).isRequired,
|
||||
contact: React.PropTypes.object
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var contactName = _getContactDisplayName(this.props.contact);
|
||||
|
||||
return (
|
||||
React.createElement("div", {className: "call-window"},
|
||||
React.createElement(CallIdentifierView, {
|
||||
peerIdentifier: contactName,
|
||||
showIcons: false}),
|
||||
React.createElement("div", null, this.props.children)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
|
||||
var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
|
||||
|
||||
var AcceptCallView = React.createClass({displayName: "AcceptCallView",
|
||||
mixins: [sharedMixins.DropdownMenuMixin()],
|
||||
|
||||
propTypes: {
|
||||
callType: React.PropTypes.string.isRequired,
|
||||
callerId: React.PropTypes.string.isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
// Only for use by the ui-showcase
|
||||
showMenu: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
showMenu: false
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.mozLoop.startAlerting();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.props.mozLoop.stopAlerting();
|
||||
},
|
||||
|
||||
clickHandler: function(e) {
|
||||
var target = e.target;
|
||||
if (!target.classList.contains("btn-chevron")) {
|
||||
this._hideDeclineMenu();
|
||||
}
|
||||
},
|
||||
|
||||
_handleAccept: function(callType) {
|
||||
return function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
|
||||
callType: callType
|
||||
}));
|
||||
}.bind(this);
|
||||
},
|
||||
|
||||
_handleDecline: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
|
||||
blockCaller: false
|
||||
}));
|
||||
},
|
||||
|
||||
_handleDeclineBlock: function(e) {
|
||||
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
|
||||
blockCaller: true
|
||||
}));
|
||||
|
||||
/* Prevent event propagation
|
||||
* stop the click from reaching parent element */
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
/*
|
||||
* Generate props for <AcceptCallButton> component based on
|
||||
* incoming call type. An incoming video call will render a video
|
||||
* answer button primarily, an audio call will flip them.
|
||||
**/
|
||||
_answerModeProps: function() {
|
||||
var videoButton = {
|
||||
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(CALL_TYPES.AUDIO_ONLY),
|
||||
className: "fx-embedded-btn-audio-small",
|
||||
tooltip: "incoming_call_accept_audio_only_tooltip"
|
||||
};
|
||||
var props = {};
|
||||
props.primary = videoButton;
|
||||
props.secondary = audioButton;
|
||||
|
||||
// When video is not enabled on this call, we swap the buttons around.
|
||||
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;
|
||||
props.secondary = videoButton;
|
||||
}
|
||||
|
||||
return props;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var dropdownMenuClassesDecline = React.addons.classSet({
|
||||
"native-dropdown-menu": true,
|
||||
"conversation-window-dropdown": true,
|
||||
"visually-hidden": !this.state.showMenu
|
||||
});
|
||||
|
||||
return (
|
||||
React.createElement("div", {className: "call-window"},
|
||||
React.createElement(CallIdentifierView, {
|
||||
peerIdentifier: this.props.callerId,
|
||||
showIcons: true,
|
||||
video: this.props.callType === CALL_TYPES.AUDIO_VIDEO}),
|
||||
|
||||
React.createElement("div", {className: "btn-group call-action-group"},
|
||||
|
||||
React.createElement("div", {className: "fx-embedded-call-button-spacer"}),
|
||||
|
||||
React.createElement("div", {className: "btn-chevron-menu-group"},
|
||||
React.createElement("div", {className: "btn-group-chevron"},
|
||||
React.createElement("div", {className: "btn-group"},
|
||||
|
||||
React.createElement("button", {className: "btn btn-decline",
|
||||
onClick: this._handleDecline},
|
||||
mozL10n.get("incoming_call_cancel_button")
|
||||
),
|
||||
React.createElement("div", {className: "btn-chevron",
|
||||
onClick: this.toggleDropdownMenu,
|
||||
ref: "menu-button"})
|
||||
),
|
||||
|
||||
React.createElement("ul", {className: dropdownMenuClassesDecline},
|
||||
React.createElement("li", {className: "btn-block", onClick: this._handleDeclineBlock},
|
||||
mozL10n.get("incoming_call_cancel_and_block_button")
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
),
|
||||
|
||||
React.createElement("div", {className: "fx-embedded-call-button-spacer"}),
|
||||
|
||||
React.createElement(AcceptCallButton, {mode: this._answerModeProps()}),
|
||||
|
||||
React.createElement("div", {className: "fx-embedded-call-button-spacer"})
|
||||
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Incoming call view accept button, renders different primary actions
|
||||
* (answer with video / with audio only) based on the props received
|
||||
**/
|
||||
var AcceptCallButton = React.createClass({displayName: "AcceptCallButton",
|
||||
|
||||
propTypes: {
|
||||
mode: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var mode = this.props.mode;
|
||||
return (
|
||||
React.createElement("div", {className: "btn-chevron-menu-group"},
|
||||
React.createElement("div", {className: "btn-group"},
|
||||
React.createElement("button", {className: "btn btn-accept",
|
||||
onClick: mode.primary.handler,
|
||||
title: mozL10n.get(mode.primary.tooltip)},
|
||||
React.createElement("span", {className: "fx-embedded-answer-btn-text"},
|
||||
mozL10n.get("incoming_call_accept_button")
|
||||
),
|
||||
React.createElement("span", {className: mode.primary.className})
|
||||
),
|
||||
React.createElement("div", {className: mode.secondary.className,
|
||||
onClick: mode.secondary.handler,
|
||||
title: mozL10n.get(mode.secondary.tooltip)}
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* View for pending conversations. Displays a cancel button and appropriate
|
||||
* pending/ringing strings.
|
||||
*/
|
||||
var PendingConversationView = React.createClass({displayName: "PendingConversationView",
|
||||
mixins: [sharedMixins.AudioMixin],
|
||||
|
||||
propTypes: {
|
||||
callState: React.PropTypes.string,
|
||||
contact: React.PropTypes.object,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
enableCancelButton: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
enableCancelButton: false
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.play("ringtone", {loop: true});
|
||||
},
|
||||
|
||||
cancelCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
var pendingStateString;
|
||||
if (this.props.callState === CALL_STATES.ALERTING) {
|
||||
pendingStateString = mozL10n.get("call_progress_ringing_description");
|
||||
} else {
|
||||
pendingStateString = mozL10n.get("call_progress_connecting_description");
|
||||
}
|
||||
|
||||
var btnCancelStyles = cx({
|
||||
"btn": true,
|
||||
"btn-cancel": true,
|
||||
"disabled": !this.props.enableCancelButton
|
||||
});
|
||||
|
||||
return (
|
||||
React.createElement(ConversationDetailView, {contact: this.props.contact},
|
||||
|
||||
React.createElement("p", {className: "btn-label"}, pendingStateString),
|
||||
|
||||
React.createElement("div", {className: "btn-group call-action-group"},
|
||||
React.createElement("button", {className: btnCancelStyles,
|
||||
onClick: this.cancelCall},
|
||||
mozL10n.get("initiate_call_cancel_button")
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Direct Call failure view. Displayed when a call fails.
|
||||
*/
|
||||
var DirectCallFailureView = React.createClass({displayName: "DirectCallFailureView",
|
||||
mixins: [
|
||||
Backbone.Events,
|
||||
loop.store.StoreMixin("conversationStore"),
|
||||
sharedMixins.AudioMixin,
|
||||
sharedMixins.WindowCloseMixin
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
// This is used by the UI showcase.
|
||||
emailLinkError: React.PropTypes.bool,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
outgoing: React.PropTypes.bool.isRequired
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return _.extend({
|
||||
emailLinkError: this.props.emailLinkError,
|
||||
emailLinkButtonDisabled: false
|
||||
}, this.getStoreState());
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.play("failure");
|
||||
this.listenTo(this.getStore(), "change:emailLink",
|
||||
this._onEmailLinkReceived);
|
||||
this.listenTo(this.getStore(), "error:emailLink",
|
||||
this._onEmailLinkError);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.stopListening(this.getStore());
|
||||
},
|
||||
|
||||
_onEmailLinkReceived: function() {
|
||||
var emailLink = this.getStoreState().emailLink;
|
||||
var contactEmail = _getPreferredEmail(this.state.contact).value;
|
||||
sharedUtils.composeCallUrlEmail(emailLink, contactEmail, null, "callfailed");
|
||||
this.closeWindow();
|
||||
},
|
||||
|
||||
_onEmailLinkError: function() {
|
||||
this.setState({
|
||||
emailLinkError: true,
|
||||
emailLinkButtonDisabled: false
|
||||
});
|
||||
},
|
||||
|
||||
retryCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.RetryCall());
|
||||
},
|
||||
|
||||
cancelCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
|
||||
},
|
||||
|
||||
emailLink: function() {
|
||||
this.setState({
|
||||
emailLinkError: false,
|
||||
emailLinkButtonDisabled: true
|
||||
});
|
||||
|
||||
this.props.dispatcher.dispatch(new sharedActions.FetchRoomEmailLink({
|
||||
roomName: _getContactDisplayName(this.state.contact)
|
||||
}));
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
|
||||
var retryClasses = cx({
|
||||
btn: true,
|
||||
"btn-info": true,
|
||||
"btn-retry": true,
|
||||
hide: !this.props.outgoing
|
||||
});
|
||||
var emailClasses = cx({
|
||||
btn: true,
|
||||
"btn-info": true,
|
||||
"btn-email": true,
|
||||
hide: !this.props.outgoing
|
||||
});
|
||||
|
||||
var settingsMenuItems = [
|
||||
{ id: "feedback" },
|
||||
{ id: "help" }
|
||||
];
|
||||
|
||||
var extraMessage;
|
||||
|
||||
if (this.props.outgoing) {
|
||||
extraMessage = mozL10n.get("generic_failure_with_reason2");
|
||||
}
|
||||
|
||||
var extraFailureMessage;
|
||||
|
||||
if (this.state.emailLinkError) {
|
||||
extraFailureMessage = mozL10n.get("unable_retrieve_url");
|
||||
}
|
||||
|
||||
return (
|
||||
React.createElement("div", {className: "direct-call-failure"},
|
||||
React.createElement(FailureInfoView, {
|
||||
contact: this.state.contact,
|
||||
extraFailureMessage: extraFailureMessage,
|
||||
extraMessage: extraMessage,
|
||||
failureReason: this.getStoreState().callStateReason}),
|
||||
|
||||
React.createElement("div", {className: "btn-group call-action-group"},
|
||||
React.createElement("button", {className: "btn btn-cancel",
|
||||
onClick: this.cancelCall},
|
||||
mozL10n.get("cancel_button")
|
||||
),
|
||||
React.createElement("button", {className: retryClasses,
|
||||
onClick: this.retryCall},
|
||||
mozL10n.get("retry_call_button")
|
||||
),
|
||||
React.createElement("button", {className: emailClasses,
|
||||
disabled: this.state.emailLinkButtonDisabled,
|
||||
onClick: this.emailLink},
|
||||
mozL10n.get("share_button3")
|
||||
)
|
||||
),
|
||||
React.createElement(loop.shared.views.SettingsControlButton, {
|
||||
menuBelow: true,
|
||||
menuItems: settingsMenuItems,
|
||||
mozLoop: this.props.mozLoop})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var OngoingConversationView = React.createClass({displayName: "OngoingConversationView",
|
||||
mixins: [
|
||||
sharedMixins.MediaSetupMixin
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
// local
|
||||
audio: React.PropTypes.object,
|
||||
chatWindowDetached: React.PropTypes.bool.isRequired,
|
||||
// We pass conversationStore here rather than use the mixin, to allow
|
||||
// easy configurability for the ui-showcase.
|
||||
conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
// The poster URLs are for UI-showcase testing and development.
|
||||
localPosterUrl: React.PropTypes.string,
|
||||
// This is used from the props rather than the state to make it easier for
|
||||
// the ui-showcase.
|
||||
mediaConnected: React.PropTypes.bool,
|
||||
mozLoop: React.PropTypes.object,
|
||||
remotePosterUrl: React.PropTypes.string,
|
||||
remoteVideoEnabled: React.PropTypes.bool,
|
||||
// local
|
||||
video: React.PropTypes.object
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
video: {enabled: true, visible: true},
|
||||
audio: {enabled: true, visible: true}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this.props.conversationStore.getStoreState();
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.props.conversationStore.on("change", function() {
|
||||
this.setState(this.props.conversationStore.getStoreState());
|
||||
}, this);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.props.conversationStore.off("change", null, this);
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
// The SDK needs to know about the configuration and the elements to use
|
||||
// for display. So the best way seems to pass the information here - ideally
|
||||
// the sdk wouldn't need to know this, but we can't change that.
|
||||
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
|
||||
publisherConfig: this.getDefaultPublisherConfig({
|
||||
publishVideo: this.props.video.enabled
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Hangs up the call.
|
||||
*/
|
||||
hangup: function() {
|
||||
this.props.dispatcher.dispatch(
|
||||
new sharedActions.HangupCall());
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to control publishing a stream - i.e. to mute a stream
|
||||
*
|
||||
* @param {String} type The type of stream, e.g. "audio" or "video".
|
||||
* @param {Boolean} enabled True to enable the stream, false otherwise.
|
||||
*/
|
||||
publishStream: function(type, enabled) {
|
||||
this.props.dispatcher.dispatch(
|
||||
new sharedActions.SetMute({
|
||||
type: type,
|
||||
enabled: enabled
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Should we render a visual cue to the user (e.g. a spinner) that a local
|
||||
* stream is on its way from the camera?
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isLocalLoading: function () {
|
||||
return !this.state.localSrcMediaElement && !this.props.localPosterUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Should we render a visual cue to the user (e.g. a spinner) that a remote
|
||||
* stream is on its way from the other user?
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isRemoteLoading: function() {
|
||||
return !!(!this.state.remoteSrcMediaElement &&
|
||||
!this.props.remotePosterUrl &&
|
||||
!this.state.mediaConnected);
|
||||
},
|
||||
|
||||
shouldRenderRemoteVideo: function() {
|
||||
if (this.props.mediaConnected) {
|
||||
// If remote video is not enabled, we're muted, so we'll show an avatar
|
||||
// instead.
|
||||
return this.props.remoteVideoEnabled;
|
||||
}
|
||||
|
||||
// We're not yet connected, but we don't want to show the avatar, and in
|
||||
// the common case, we'll just transition to the video.
|
||||
return true;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// 'visible' and 'enabled' are true by default.
|
||||
var settingsMenuItems = [
|
||||
{
|
||||
id: "edit",
|
||||
visible: false,
|
||||
enabled: false
|
||||
},
|
||||
{ id: "feedback" },
|
||||
{ id: "help" }
|
||||
];
|
||||
return (
|
||||
React.createElement("div", {className: "desktop-call-wrapper"},
|
||||
React.createElement(sharedViews.MediaLayoutView, {
|
||||
dispatcher: this.props.dispatcher,
|
||||
displayScreenShare: false,
|
||||
isLocalLoading: this._isLocalLoading(),
|
||||
isRemoteLoading: this._isRemoteLoading(),
|
||||
isScreenShareLoading: false,
|
||||
localPosterUrl: this.props.localPosterUrl,
|
||||
localSrcMediaElement: this.state.localSrcMediaElement,
|
||||
localVideoMuted: !this.props.video.enabled,
|
||||
matchMedia: this.state.matchMedia || window.matchMedia.bind(window),
|
||||
remotePosterUrl: this.props.remotePosterUrl,
|
||||
remoteSrcMediaElement: this.state.remoteSrcMediaElement,
|
||||
renderRemoteVideo: this.shouldRenderRemoteVideo(),
|
||||
screenShareMediaElement: this.state.screenShareMediaElement,
|
||||
screenSharePosterUrl: null,
|
||||
showContextRoomName: false,
|
||||
useDesktopPaths: true},
|
||||
React.createElement(sharedViews.ConversationToolbar, {
|
||||
audio: this.props.audio,
|
||||
dispatcher: this.props.dispatcher,
|
||||
hangup: this.hangup,
|
||||
mozLoop: this.props.mozLoop,
|
||||
publishStream: this.publishStream,
|
||||
settingsMenuItems: settingsMenuItems,
|
||||
show: true,
|
||||
showHangup: this.props.chatWindowDetached,
|
||||
video: this.props.video})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Master View Controller for outgoing calls. This manages
|
||||
* the different views that need displaying.
|
||||
*/
|
||||
var CallControllerView = React.createClass({displayName: "CallControllerView",
|
||||
mixins: [
|
||||
sharedMixins.AudioMixin,
|
||||
sharedMixins.DocumentTitleMixin,
|
||||
loop.store.StoreMixin("conversationStore"),
|
||||
Backbone.Events
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
chatWindowDetached: React.PropTypes.bool.isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
onCallTerminated: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this.getStoreState();
|
||||
},
|
||||
|
||||
_closeWindow: function() {
|
||||
window.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if the call is in a cancellable state, during call setup.
|
||||
*/
|
||||
_isCancellable: function() {
|
||||
return this.state.callState !== CALL_STATES.INIT &&
|
||||
this.state.callState !== CALL_STATES.GATHER;
|
||||
},
|
||||
|
||||
_renderViewFromCallType: function() {
|
||||
// For outgoing calls we can display the pending conversation view
|
||||
// for any state that render() doesn't manage.
|
||||
if (this.state.outgoing) {
|
||||
return (React.createElement(PendingConversationView, {
|
||||
callState: this.state.callState,
|
||||
contact: this.state.contact,
|
||||
dispatcher: this.props.dispatcher,
|
||||
enableCancelButton: this._isCancellable()}));
|
||||
}
|
||||
|
||||
// For incoming calls that are in accepting state, display the
|
||||
// accept call view.
|
||||
if (this.state.callState === CALL_STATES.ALERTING) {
|
||||
return (React.createElement(AcceptCallView, {
|
||||
callType: this.state.callType,
|
||||
callerId: this.state.callerId,
|
||||
dispatcher: this.props.dispatcher,
|
||||
mozLoop: this.props.mozLoop}
|
||||
));
|
||||
}
|
||||
|
||||
// Otherwise we're still gathering or connecting, so
|
||||
// don't display anything.
|
||||
return null;
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps, prevState) {
|
||||
// Handle timestamp and window closing only when the call has terminated.
|
||||
if (prevState.callState === CALL_STATES.ONGOING &&
|
||||
this.state.callState === CALL_STATES.FINISHED) {
|
||||
this.props.onCallTerminated();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Set the default title to the contact name or the callerId, note
|
||||
// that views may override this, e.g. the feedback view.
|
||||
if (this.state.contact) {
|
||||
this.setTitle(_getContactDisplayName(this.state.contact));
|
||||
} else {
|
||||
this.setTitle(this.state.callerId || "");
|
||||
}
|
||||
|
||||
switch (this.state.callState) {
|
||||
case CALL_STATES.CLOSE: {
|
||||
this._closeWindow();
|
||||
return null;
|
||||
}
|
||||
case CALL_STATES.TERMINATED: {
|
||||
return (React.createElement(DirectCallFailureView, {
|
||||
dispatcher: this.props.dispatcher,
|
||||
mozLoop: this.props.mozLoop,
|
||||
outgoing: this.state.outgoing}));
|
||||
}
|
||||
case CALL_STATES.ONGOING: {
|
||||
return (React.createElement(OngoingConversationView, {
|
||||
audio: { enabled: !this.state.audioMuted, visible: true},
|
||||
chatWindowDetached: this.props.chatWindowDetached,
|
||||
conversationStore: this.getStore(),
|
||||
dispatcher: this.props.dispatcher,
|
||||
mediaConnected: this.state.mediaConnected,
|
||||
mozLoop: this.props.mozLoop,
|
||||
remoteSrcMediaElement: this.state.remoteSrcMediaElement,
|
||||
remoteVideoEnabled: this.state.remoteVideoEnabled,
|
||||
video: { enabled: !this.state.videoMuted, visible: true}})
|
||||
);
|
||||
}
|
||||
case CALL_STATES.FINISHED: {
|
||||
this.play("terminated");
|
||||
|
||||
// When conversation ended we either display a feedback form or
|
||||
// close the window. This is decided in the AppControllerView.
|
||||
return null;
|
||||
}
|
||||
case CALL_STATES.INIT: {
|
||||
// We know what we are, but we haven't got the data yet.
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
return this._renderViewFromCallType();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
PendingConversationView: PendingConversationView,
|
||||
CallIdentifierView: CallIdentifierView,
|
||||
ConversationDetailView: ConversationDetailView,
|
||||
_getContactDisplayName: _getContactDisplayName,
|
||||
FailureInfoView: FailureInfoView,
|
||||
DirectCallFailureView: DirectCallFailureView,
|
||||
AcceptCallView: AcceptCallView,
|
||||
OngoingConversationView: OngoingConversationView,
|
||||
CallControllerView: CallControllerView
|
||||
};
|
||||
|
||||
})(document.mozL10n || navigator.mozL10n);
|
@ -1,805 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.conversationViews = (function(mozL10n) {
|
||||
"use strict";
|
||||
|
||||
var CALL_STATES = loop.store.CALL_STATES;
|
||||
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
|
||||
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
var sharedActions = loop.shared.actions;
|
||||
var sharedUtils = loop.shared.utils;
|
||||
var sharedViews = loop.shared.views;
|
||||
var sharedMixins = loop.shared.mixins;
|
||||
|
||||
// This duplicates a similar function in contacts.jsx that isn't used in the
|
||||
// conversation window. If we get too many of these, we might want to consider
|
||||
// finding a logical place for them to be shared.
|
||||
|
||||
// XXXdmose this code is already out of sync with the code in contacts.jsx
|
||||
// which, unlike this code, now has unit tests. We should totally do the
|
||||
// above.
|
||||
|
||||
function _getPreferredEmail(contact) {
|
||||
// A contact may not contain email addresses, but only a phone number.
|
||||
if (!contact.email || contact.email.length === 0) {
|
||||
return { value: "" };
|
||||
}
|
||||
return contact.email.find(function find(e) { return e.pref; }) || contact.email[0];
|
||||
}
|
||||
|
||||
function _getContactDisplayName(contact) {
|
||||
if (contact.name && contact.name[0]) {
|
||||
return contact.name[0];
|
||||
}
|
||||
return _getPreferredEmail(contact).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays information about the call
|
||||
* Caller avatar, name & conversation creation date
|
||||
*/
|
||||
var CallIdentifierView = React.createClass({
|
||||
propTypes: {
|
||||
peerIdentifier: React.PropTypes.string,
|
||||
showIcons: React.PropTypes.bool.isRequired,
|
||||
urlCreationDate: React.PropTypes.string,
|
||||
video: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
peerIdentifier: "",
|
||||
showLinkDetail: true,
|
||||
urlCreationDate: "",
|
||||
video: true
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {timestamp: 0};
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets and formats the incoming call creation date
|
||||
*/
|
||||
formatCreationDate: function() {
|
||||
if (!this.props.urlCreationDate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
var timestamp = this.props.urlCreationDate;
|
||||
return "(" + loop.shared.utils.formatDate(timestamp) + ")";
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var iconVideoClasses = React.addons.classSet({
|
||||
"fx-embedded-tiny-video-icon": true,
|
||||
"muted": !this.props.video
|
||||
});
|
||||
var callDetailClasses = React.addons.classSet({
|
||||
"fx-embedded-call-detail": true,
|
||||
"hide": !this.props.showIcons
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fx-embedded-call-identifier">
|
||||
<div className="fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"/>
|
||||
<div className="fx-embedded-call-identifier-info fx-embedded-call-identifier-item">
|
||||
<div className="fx-embedded-call-identifier-text overflow-text-ellipsis">
|
||||
{this.props.peerIdentifier}
|
||||
</div>
|
||||
<div className={callDetailClasses}>
|
||||
<span className="fx-embedded-tiny-audio-icon"></span>
|
||||
<span className={iconVideoClasses}></span>
|
||||
<span className="fx-embedded-conversation-timestamp">
|
||||
{this.formatCreationDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Displays details of the incoming/outgoing conversation
|
||||
* (name, link, audio/video type etc).
|
||||
*
|
||||
* Allows the view to be extended with different buttons and progress
|
||||
* via children properties.
|
||||
*/
|
||||
var ConversationDetailView = React.createClass({
|
||||
propTypes: {
|
||||
children: React.PropTypes.oneOfType([
|
||||
React.PropTypes.element,
|
||||
React.PropTypes.arrayOf(React.PropTypes.element)
|
||||
]).isRequired,
|
||||
contact: React.PropTypes.object
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var contactName = _getContactDisplayName(this.props.contact);
|
||||
|
||||
return (
|
||||
<div className="call-window">
|
||||
<CallIdentifierView
|
||||
peerIdentifier={contactName}
|
||||
showIcons={false} />
|
||||
<div>{this.props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
|
||||
var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
|
||||
|
||||
var AcceptCallView = React.createClass({
|
||||
mixins: [sharedMixins.DropdownMenuMixin()],
|
||||
|
||||
propTypes: {
|
||||
callType: React.PropTypes.string.isRequired,
|
||||
callerId: React.PropTypes.string.isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
// Only for use by the ui-showcase
|
||||
showMenu: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
showMenu: false
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.mozLoop.startAlerting();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.props.mozLoop.stopAlerting();
|
||||
},
|
||||
|
||||
clickHandler: function(e) {
|
||||
var target = e.target;
|
||||
if (!target.classList.contains("btn-chevron")) {
|
||||
this._hideDeclineMenu();
|
||||
}
|
||||
},
|
||||
|
||||
_handleAccept: function(callType) {
|
||||
return function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
|
||||
callType: callType
|
||||
}));
|
||||
}.bind(this);
|
||||
},
|
||||
|
||||
_handleDecline: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
|
||||
blockCaller: false
|
||||
}));
|
||||
},
|
||||
|
||||
_handleDeclineBlock: function(e) {
|
||||
this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
|
||||
blockCaller: true
|
||||
}));
|
||||
|
||||
/* Prevent event propagation
|
||||
* stop the click from reaching parent element */
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
/*
|
||||
* Generate props for <AcceptCallButton> component based on
|
||||
* incoming call type. An incoming video call will render a video
|
||||
* answer button primarily, an audio call will flip them.
|
||||
**/
|
||||
_answerModeProps: function() {
|
||||
var videoButton = {
|
||||
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(CALL_TYPES.AUDIO_ONLY),
|
||||
className: "fx-embedded-btn-audio-small",
|
||||
tooltip: "incoming_call_accept_audio_only_tooltip"
|
||||
};
|
||||
var props = {};
|
||||
props.primary = videoButton;
|
||||
props.secondary = audioButton;
|
||||
|
||||
// When video is not enabled on this call, we swap the buttons around.
|
||||
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;
|
||||
props.secondary = videoButton;
|
||||
}
|
||||
|
||||
return props;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var dropdownMenuClassesDecline = React.addons.classSet({
|
||||
"native-dropdown-menu": true,
|
||||
"conversation-window-dropdown": true,
|
||||
"visually-hidden": !this.state.showMenu
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="call-window">
|
||||
<CallIdentifierView
|
||||
peerIdentifier={this.props.callerId}
|
||||
showIcons={true}
|
||||
video={this.props.callType === CALL_TYPES.AUDIO_VIDEO} />
|
||||
|
||||
<div className="btn-group call-action-group">
|
||||
|
||||
<div className="fx-embedded-call-button-spacer"></div>
|
||||
|
||||
<div className="btn-chevron-menu-group">
|
||||
<div className="btn-group-chevron">
|
||||
<div className="btn-group">
|
||||
|
||||
<button className="btn btn-decline"
|
||||
onClick={this._handleDecline}>
|
||||
{mozL10n.get("incoming_call_cancel_button")}
|
||||
</button>
|
||||
<div className="btn-chevron"
|
||||
onClick={this.toggleDropdownMenu}
|
||||
ref="menu-button" />
|
||||
</div>
|
||||
|
||||
<ul className={dropdownMenuClassesDecline}>
|
||||
<li className="btn-block" onClick={this._handleDeclineBlock}>
|
||||
{mozL10n.get("incoming_call_cancel_and_block_button")}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fx-embedded-call-button-spacer"></div>
|
||||
|
||||
<AcceptCallButton mode={this._answerModeProps()} />
|
||||
|
||||
<div className="fx-embedded-call-button-spacer"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Incoming call view accept button, renders different primary actions
|
||||
* (answer with video / with audio only) based on the props received
|
||||
**/
|
||||
var AcceptCallButton = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
mode: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var mode = this.props.mode;
|
||||
return (
|
||||
<div className="btn-chevron-menu-group">
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-accept"
|
||||
onClick={mode.primary.handler}
|
||||
title={mozL10n.get(mode.primary.tooltip)}>
|
||||
<span className="fx-embedded-answer-btn-text">
|
||||
{mozL10n.get("incoming_call_accept_button")}
|
||||
</span>
|
||||
<span className={mode.primary.className}></span>
|
||||
</button>
|
||||
<div className={mode.secondary.className}
|
||||
onClick={mode.secondary.handler}
|
||||
title={mozL10n.get(mode.secondary.tooltip)}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* View for pending conversations. Displays a cancel button and appropriate
|
||||
* pending/ringing strings.
|
||||
*/
|
||||
var PendingConversationView = React.createClass({
|
||||
mixins: [sharedMixins.AudioMixin],
|
||||
|
||||
propTypes: {
|
||||
callState: React.PropTypes.string,
|
||||
contact: React.PropTypes.object,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
enableCancelButton: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
enableCancelButton: false
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.play("ringtone", {loop: true});
|
||||
},
|
||||
|
||||
cancelCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
var pendingStateString;
|
||||
if (this.props.callState === CALL_STATES.ALERTING) {
|
||||
pendingStateString = mozL10n.get("call_progress_ringing_description");
|
||||
} else {
|
||||
pendingStateString = mozL10n.get("call_progress_connecting_description");
|
||||
}
|
||||
|
||||
var btnCancelStyles = cx({
|
||||
"btn": true,
|
||||
"btn-cancel": true,
|
||||
"disabled": !this.props.enableCancelButton
|
||||
});
|
||||
|
||||
return (
|
||||
<ConversationDetailView contact={this.props.contact}>
|
||||
|
||||
<p className="btn-label">{pendingStateString}</p>
|
||||
|
||||
<div className="btn-group call-action-group">
|
||||
<button className={btnCancelStyles}
|
||||
onClick={this.cancelCall}>
|
||||
{mozL10n.get("initiate_call_cancel_button")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ConversationDetailView>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Direct Call failure view. Displayed when a call fails.
|
||||
*/
|
||||
var DirectCallFailureView = React.createClass({
|
||||
mixins: [
|
||||
Backbone.Events,
|
||||
loop.store.StoreMixin("conversationStore"),
|
||||
sharedMixins.AudioMixin,
|
||||
sharedMixins.WindowCloseMixin
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
// This is used by the UI showcase.
|
||||
emailLinkError: React.PropTypes.bool,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
outgoing: React.PropTypes.bool.isRequired
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return _.extend({
|
||||
emailLinkError: this.props.emailLinkError,
|
||||
emailLinkButtonDisabled: false
|
||||
}, this.getStoreState());
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.play("failure");
|
||||
this.listenTo(this.getStore(), "change:emailLink",
|
||||
this._onEmailLinkReceived);
|
||||
this.listenTo(this.getStore(), "error:emailLink",
|
||||
this._onEmailLinkError);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.stopListening(this.getStore());
|
||||
},
|
||||
|
||||
_onEmailLinkReceived: function() {
|
||||
var emailLink = this.getStoreState().emailLink;
|
||||
var contactEmail = _getPreferredEmail(this.state.contact).value;
|
||||
sharedUtils.composeCallUrlEmail(emailLink, contactEmail, null, "callfailed");
|
||||
this.closeWindow();
|
||||
},
|
||||
|
||||
_onEmailLinkError: function() {
|
||||
this.setState({
|
||||
emailLinkError: true,
|
||||
emailLinkButtonDisabled: false
|
||||
});
|
||||
},
|
||||
|
||||
retryCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.RetryCall());
|
||||
},
|
||||
|
||||
cancelCall: function() {
|
||||
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
|
||||
},
|
||||
|
||||
emailLink: function() {
|
||||
this.setState({
|
||||
emailLinkError: false,
|
||||
emailLinkButtonDisabled: true
|
||||
});
|
||||
|
||||
this.props.dispatcher.dispatch(new sharedActions.FetchRoomEmailLink({
|
||||
roomName: _getContactDisplayName(this.state.contact)
|
||||
}));
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var cx = React.addons.classSet;
|
||||
|
||||
var retryClasses = cx({
|
||||
btn: true,
|
||||
"btn-info": true,
|
||||
"btn-retry": true,
|
||||
hide: !this.props.outgoing
|
||||
});
|
||||
var emailClasses = cx({
|
||||
btn: true,
|
||||
"btn-info": true,
|
||||
"btn-email": true,
|
||||
hide: !this.props.outgoing
|
||||
});
|
||||
|
||||
var settingsMenuItems = [
|
||||
{ id: "feedback" },
|
||||
{ id: "help" }
|
||||
];
|
||||
|
||||
var extraMessage;
|
||||
|
||||
if (this.props.outgoing) {
|
||||
extraMessage = mozL10n.get("generic_failure_with_reason2");
|
||||
}
|
||||
|
||||
var extraFailureMessage;
|
||||
|
||||
if (this.state.emailLinkError) {
|
||||
extraFailureMessage = mozL10n.get("unable_retrieve_url");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="direct-call-failure">
|
||||
<FailureInfoView
|
||||
contact={this.state.contact}
|
||||
extraFailureMessage={extraFailureMessage}
|
||||
extraMessage={extraMessage}
|
||||
failureReason={this.getStoreState().callStateReason}/>
|
||||
|
||||
<div className="btn-group call-action-group">
|
||||
<button className="btn btn-cancel"
|
||||
onClick={this.cancelCall}>
|
||||
{mozL10n.get("cancel_button")}
|
||||
</button>
|
||||
<button className={retryClasses}
|
||||
onClick={this.retryCall}>
|
||||
{mozL10n.get("retry_call_button")}
|
||||
</button>
|
||||
<button className={emailClasses}
|
||||
disabled={this.state.emailLinkButtonDisabled}
|
||||
onClick={this.emailLink}>
|
||||
{mozL10n.get("share_button3")}
|
||||
</button>
|
||||
</div>
|
||||
<loop.shared.views.SettingsControlButton
|
||||
menuBelow={true}
|
||||
menuItems={settingsMenuItems}
|
||||
mozLoop={this.props.mozLoop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var OngoingConversationView = React.createClass({
|
||||
mixins: [
|
||||
sharedMixins.MediaSetupMixin
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
// local
|
||||
audio: React.PropTypes.object,
|
||||
chatWindowDetached: React.PropTypes.bool.isRequired,
|
||||
// We pass conversationStore here rather than use the mixin, to allow
|
||||
// easy configurability for the ui-showcase.
|
||||
conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
// The poster URLs are for UI-showcase testing and development.
|
||||
localPosterUrl: React.PropTypes.string,
|
||||
// This is used from the props rather than the state to make it easier for
|
||||
// the ui-showcase.
|
||||
mediaConnected: React.PropTypes.bool,
|
||||
mozLoop: React.PropTypes.object,
|
||||
remotePosterUrl: React.PropTypes.string,
|
||||
remoteVideoEnabled: React.PropTypes.bool,
|
||||
// local
|
||||
video: React.PropTypes.object
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
video: {enabled: true, visible: true},
|
||||
audio: {enabled: true, visible: true}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this.props.conversationStore.getStoreState();
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.props.conversationStore.on("change", function() {
|
||||
this.setState(this.props.conversationStore.getStoreState());
|
||||
}, this);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.props.conversationStore.off("change", null, this);
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
// The SDK needs to know about the configuration and the elements to use
|
||||
// for display. So the best way seems to pass the information here - ideally
|
||||
// the sdk wouldn't need to know this, but we can't change that.
|
||||
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
|
||||
publisherConfig: this.getDefaultPublisherConfig({
|
||||
publishVideo: this.props.video.enabled
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Hangs up the call.
|
||||
*/
|
||||
hangup: function() {
|
||||
this.props.dispatcher.dispatch(
|
||||
new sharedActions.HangupCall());
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to control publishing a stream - i.e. to mute a stream
|
||||
*
|
||||
* @param {String} type The type of stream, e.g. "audio" or "video".
|
||||
* @param {Boolean} enabled True to enable the stream, false otherwise.
|
||||
*/
|
||||
publishStream: function(type, enabled) {
|
||||
this.props.dispatcher.dispatch(
|
||||
new sharedActions.SetMute({
|
||||
type: type,
|
||||
enabled: enabled
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Should we render a visual cue to the user (e.g. a spinner) that a local
|
||||
* stream is on its way from the camera?
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isLocalLoading: function () {
|
||||
return !this.state.localSrcMediaElement && !this.props.localPosterUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Should we render a visual cue to the user (e.g. a spinner) that a remote
|
||||
* stream is on its way from the other user?
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isRemoteLoading: function() {
|
||||
return !!(!this.state.remoteSrcMediaElement &&
|
||||
!this.props.remotePosterUrl &&
|
||||
!this.state.mediaConnected);
|
||||
},
|
||||
|
||||
shouldRenderRemoteVideo: function() {
|
||||
if (this.props.mediaConnected) {
|
||||
// If remote video is not enabled, we're muted, so we'll show an avatar
|
||||
// instead.
|
||||
return this.props.remoteVideoEnabled;
|
||||
}
|
||||
|
||||
// We're not yet connected, but we don't want to show the avatar, and in
|
||||
// the common case, we'll just transition to the video.
|
||||
return true;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// 'visible' and 'enabled' are true by default.
|
||||
var settingsMenuItems = [
|
||||
{
|
||||
id: "edit",
|
||||
visible: false,
|
||||
enabled: false
|
||||
},
|
||||
{ id: "feedback" },
|
||||
{ id: "help" }
|
||||
];
|
||||
return (
|
||||
<div className="desktop-call-wrapper">
|
||||
<sharedViews.MediaLayoutView
|
||||
dispatcher={this.props.dispatcher}
|
||||
displayScreenShare={false}
|
||||
isLocalLoading={this._isLocalLoading()}
|
||||
isRemoteLoading={this._isRemoteLoading()}
|
||||
isScreenShareLoading={false}
|
||||
localPosterUrl={this.props.localPosterUrl}
|
||||
localSrcMediaElement={this.state.localSrcMediaElement}
|
||||
localVideoMuted={!this.props.video.enabled}
|
||||
matchMedia={this.state.matchMedia || window.matchMedia.bind(window)}
|
||||
remotePosterUrl={this.props.remotePosterUrl}
|
||||
remoteSrcMediaElement={this.state.remoteSrcMediaElement}
|
||||
renderRemoteVideo={this.shouldRenderRemoteVideo()}
|
||||
screenShareMediaElement={this.state.screenShareMediaElement}
|
||||
screenSharePosterUrl={null}
|
||||
showContextRoomName={false}
|
||||
useDesktopPaths={true}>
|
||||
<sharedViews.ConversationToolbar
|
||||
audio={this.props.audio}
|
||||
dispatcher={this.props.dispatcher}
|
||||
hangup={this.hangup}
|
||||
mozLoop={this.props.mozLoop}
|
||||
publishStream={this.publishStream}
|
||||
settingsMenuItems={settingsMenuItems}
|
||||
show={true}
|
||||
showHangup={this.props.chatWindowDetached}
|
||||
video={this.props.video} />
|
||||
</sharedViews.MediaLayoutView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Master View Controller for outgoing calls. This manages
|
||||
* the different views that need displaying.
|
||||
*/
|
||||
var CallControllerView = React.createClass({
|
||||
mixins: [
|
||||
sharedMixins.AudioMixin,
|
||||
sharedMixins.DocumentTitleMixin,
|
||||
loop.store.StoreMixin("conversationStore"),
|
||||
Backbone.Events
|
||||
],
|
||||
|
||||
propTypes: {
|
||||
chatWindowDetached: React.PropTypes.bool.isRequired,
|
||||
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
|
||||
mozLoop: React.PropTypes.object.isRequired,
|
||||
onCallTerminated: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return this.getStoreState();
|
||||
},
|
||||
|
||||
_closeWindow: function() {
|
||||
window.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if the call is in a cancellable state, during call setup.
|
||||
*/
|
||||
_isCancellable: function() {
|
||||
return this.state.callState !== CALL_STATES.INIT &&
|
||||
this.state.callState !== CALL_STATES.GATHER;
|
||||
},
|
||||
|
||||
_renderViewFromCallType: function() {
|
||||
// For outgoing calls we can display the pending conversation view
|
||||
// for any state that render() doesn't manage.
|
||||
if (this.state.outgoing) {
|
||||
return (<PendingConversationView
|
||||
callState={this.state.callState}
|
||||
contact={this.state.contact}
|
||||
dispatcher={this.props.dispatcher}
|
||||
enableCancelButton={this._isCancellable()} />);
|
||||
}
|
||||
|
||||
// For incoming calls that are in accepting state, display the
|
||||
// accept call view.
|
||||
if (this.state.callState === CALL_STATES.ALERTING) {
|
||||
return (<AcceptCallView
|
||||
callType={this.state.callType}
|
||||
callerId={this.state.callerId}
|
||||
dispatcher={this.props.dispatcher}
|
||||
mozLoop={this.props.mozLoop}
|
||||
/>);
|
||||
}
|
||||
|
||||
// Otherwise we're still gathering or connecting, so
|
||||
// don't display anything.
|
||||
return null;
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps, prevState) {
|
||||
// Handle timestamp and window closing only when the call has terminated.
|
||||
if (prevState.callState === CALL_STATES.ONGOING &&
|
||||
this.state.callState === CALL_STATES.FINISHED) {
|
||||
this.props.onCallTerminated();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Set the default title to the contact name or the callerId, note
|
||||
// that views may override this, e.g. the feedback view.
|
||||
if (this.state.contact) {
|
||||
this.setTitle(_getContactDisplayName(this.state.contact));
|
||||
} else {
|
||||
this.setTitle(this.state.callerId || "");
|
||||
}
|
||||
|
||||
switch (this.state.callState) {
|
||||
case CALL_STATES.CLOSE: {
|
||||
this._closeWindow();
|
||||
return null;
|
||||
}
|
||||
case CALL_STATES.TERMINATED: {
|
||||
return (<DirectCallFailureView
|
||||
dispatcher={this.props.dispatcher}
|
||||
mozLoop={this.props.mozLoop}
|
||||
outgoing={this.state.outgoing} />);
|
||||
}
|
||||
case CALL_STATES.ONGOING: {
|
||||
return (<OngoingConversationView
|
||||
audio={{ enabled: !this.state.audioMuted, visible: true }}
|
||||
chatWindowDetached={this.props.chatWindowDetached}
|
||||
conversationStore={this.getStore()}
|
||||
dispatcher={this.props.dispatcher}
|
||||
mediaConnected={this.state.mediaConnected}
|
||||
mozLoop={this.props.mozLoop}
|
||||
remoteSrcMediaElement={this.state.remoteSrcMediaElement}
|
||||
remoteVideoEnabled={this.state.remoteVideoEnabled}
|
||||
video={{ enabled: !this.state.videoMuted, visible: true }} />
|
||||
);
|
||||
}
|
||||
case CALL_STATES.FINISHED: {
|
||||
this.play("terminated");
|
||||
|
||||
// When conversation ended we either display a feedback form or
|
||||
// close the window. This is decided in the AppControllerView.
|
||||
return null;
|
||||
}
|
||||
case CALL_STATES.INIT: {
|
||||
// We know what we are, but we haven't got the data yet.
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
return this._renderViewFromCallType();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
PendingConversationView: PendingConversationView,
|
||||
CallIdentifierView: CallIdentifierView,
|
||||
ConversationDetailView: ConversationDetailView,
|
||||
_getContactDisplayName: _getContactDisplayName,
|
||||
FailureInfoView: FailureInfoView,
|
||||
DirectCallFailureView: DirectCallFailureView,
|
||||
AcceptCallView: AcceptCallView,
|
||||
OngoingConversationView: OngoingConversationView,
|
||||
CallControllerView: CallControllerView
|
||||
};
|
||||
|
||||
})(document.mozL10n || navigator.mozL10n);
|
Binary file not shown.
Before Width: | Height: | Size: 190 B |
Binary file not shown.
Before Width: | Height: | Size: 340 B |
Binary file not shown.
Before Width: | Height: | Size: 92 B |
Binary file not shown.
Before Width: | Height: | Size: 119 B |
Binary file not shown.
Before Width: | Height: | Size: 155 B |
Binary file not shown.
Before Width: | Height: | Size: 243 B |
@ -1,676 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.store = loop.store || {};
|
||||
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
var sharedActions = loop.shared.actions;
|
||||
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
|
||||
var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
|
||||
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
|
||||
/**
|
||||
* Websocket states taken from:
|
||||
* https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
|
||||
*/
|
||||
var WS_STATES = loop.store.WS_STATES = {
|
||||
// The call is starting, and the remote party is not yet being alerted.
|
||||
INIT: "init",
|
||||
// The called party is being alerted.
|
||||
ALERTING: "alerting",
|
||||
// The call is no longer being set up and has been aborted for some reason.
|
||||
TERMINATED: "terminated",
|
||||
// The called party has indicated that he has answered the call,
|
||||
// but the media is not yet confirmed.
|
||||
CONNECTING: "connecting",
|
||||
// One of the two parties has indicated successful media set up,
|
||||
// but the other has not yet.
|
||||
HALF_CONNECTED: "half-connected",
|
||||
// Both endpoints have reported successfully establishing media.
|
||||
CONNECTED: "connected"
|
||||
};
|
||||
|
||||
var CALL_STATES = loop.store.CALL_STATES = {
|
||||
// The initial state of the view.
|
||||
INIT: "cs-init",
|
||||
// The store is gathering the call data from the server.
|
||||
GATHER: "cs-gather",
|
||||
// The initial data has been gathered, the websocket is connecting, or has
|
||||
// connected, and waiting for the other side to connect to the server.
|
||||
CONNECTING: "cs-connecting",
|
||||
// The websocket has received information that we're now alerting
|
||||
// the peer.
|
||||
ALERTING: "cs-alerting",
|
||||
// The call is ongoing.
|
||||
ONGOING: "cs-ongoing",
|
||||
// The call ended successfully.
|
||||
FINISHED: "cs-finished",
|
||||
// The user has finished with the window.
|
||||
CLOSE: "cs-close",
|
||||
// The call was terminated due to an issue during connection.
|
||||
TERMINATED: "cs-terminated"
|
||||
};
|
||||
|
||||
/**
|
||||
* Conversation store.
|
||||
*
|
||||
* @param {loop.Dispatcher} dispatcher The dispatcher for dispatching actions
|
||||
* and registering to consume actions.
|
||||
* @param {Object} options Options object:
|
||||
* - {client} client The client object.
|
||||
* - {mozLoop} mozLoop The MozLoop API object.
|
||||
* - {loop.OTSdkDriver} loop.OTSdkDriver The SDK Driver
|
||||
*/
|
||||
loop.store.ConversationStore = loop.store.createStore({
|
||||
// Further actions are registered in setupWindowData when
|
||||
// we know what window type this is.
|
||||
actions: [
|
||||
"setupWindowData"
|
||||
],
|
||||
|
||||
getInitialStoreState: function() {
|
||||
return {
|
||||
// The id of the window. Currently used for getting the window id.
|
||||
windowId: undefined,
|
||||
// The current state of the call
|
||||
callState: CALL_STATES.INIT,
|
||||
// The reason if a call was terminated
|
||||
callStateReason: undefined,
|
||||
// True if the call is outgoing, false if not, undefined if unknown
|
||||
outgoing: undefined,
|
||||
// The contact being called for outgoing calls
|
||||
contact: undefined,
|
||||
// The call type for the call.
|
||||
// XXX Don't hard-code, this comes from the data in bug 1072323
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
// A link for emailing once obtained from the server
|
||||
emailLink: undefined,
|
||||
|
||||
// Call Connection information
|
||||
// The call id from the loop-server
|
||||
callId: undefined,
|
||||
// The caller id of the contacting side
|
||||
callerId: undefined,
|
||||
// True if media has been connected both-ways.
|
||||
mediaConnected: false,
|
||||
// The connection progress url to connect the websocket
|
||||
progressURL: undefined,
|
||||
// The websocket token that allows connection to the progress url
|
||||
websocketToken: undefined,
|
||||
// SDK API key
|
||||
apiKey: undefined,
|
||||
// SDK session ID
|
||||
sessionId: undefined,
|
||||
// SDK session token
|
||||
sessionToken: undefined,
|
||||
// If the local audio is muted
|
||||
audioMuted: false,
|
||||
// If the local video is muted
|
||||
videoMuted: false,
|
||||
remoteVideoEnabled: false
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles initialisation of the store.
|
||||
*
|
||||
* @param {Object} options Options object.
|
||||
*/
|
||||
initialize: function(options) {
|
||||
options = options || {};
|
||||
|
||||
if (!options.client) {
|
||||
throw new Error("Missing option client");
|
||||
}
|
||||
if (!options.sdkDriver) {
|
||||
throw new Error("Missing option sdkDriver");
|
||||
}
|
||||
if (!options.mozLoop) {
|
||||
throw new Error("Missing option mozLoop");
|
||||
}
|
||||
|
||||
this.client = options.client;
|
||||
this.sdkDriver = options.sdkDriver;
|
||||
this.mozLoop = options.mozLoop;
|
||||
this._isDesktop = options.isDesktop || false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles the connection failure action, setting the state to
|
||||
* terminated.
|
||||
*
|
||||
* @param {sharedActions.ConnectionFailure} actionData The action data.
|
||||
*/
|
||||
connectionFailure: function(actionData) {
|
||||
this._endSession();
|
||||
this.setStoreState({
|
||||
callState: CALL_STATES.TERMINATED,
|
||||
callStateReason: actionData.reason
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles the connection progress action, setting the next state
|
||||
* appropriately.
|
||||
*
|
||||
* @param {sharedActions.ConnectionProgress} actionData The action data.
|
||||
*/
|
||||
connectionProgress: function(actionData) {
|
||||
var state = this.getStoreState();
|
||||
|
||||
switch(actionData.wsState) {
|
||||
case WS_STATES.INIT: {
|
||||
if (state.callState === CALL_STATES.GATHER) {
|
||||
this.setStoreState({callState: CALL_STATES.CONNECTING});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_STATES.ALERTING: {
|
||||
this.setStoreState({callState: CALL_STATES.ALERTING});
|
||||
break;
|
||||
}
|
||||
case WS_STATES.CONNECTING: {
|
||||
if (state.outgoing) {
|
||||
// We can start the connection right away if we're the outgoing
|
||||
// call because this is the only way we know the other peer has
|
||||
// accepted.
|
||||
this._startCallConnection();
|
||||
} else if (state.callState !== CALL_STATES.ONGOING) {
|
||||
console.error("Websocket unexpectedly changed to next state whilst waiting for call acceptance.");
|
||||
// Abort and close the window.
|
||||
this.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_STATES.HALF_CONNECTED:
|
||||
case WS_STATES.CONNECTED: {
|
||||
if (this.getStoreState("callState") !== CALL_STATES.ONGOING) {
|
||||
console.error("Unexpected websocket state received in wrong callState");
|
||||
this.setStoreState({callState: CALL_STATES.ONGOING});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error("Unexpected websocket state passed to connectionProgress:",
|
||||
actionData.wsState);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setupWindowData: function(actionData) {
|
||||
var windowType = actionData.type;
|
||||
if (windowType !== "outgoing" &&
|
||||
windowType !== "incoming") {
|
||||
// Not for this store, don't do anything.
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatcher.register(this, [
|
||||
"connectionFailure",
|
||||
"connectionProgress",
|
||||
"acceptCall",
|
||||
"declineCall",
|
||||
"connectCall",
|
||||
"hangupCall",
|
||||
"remotePeerDisconnected",
|
||||
"cancelCall",
|
||||
"retryCall",
|
||||
"mediaConnected",
|
||||
"setMute",
|
||||
"fetchRoomEmailLink",
|
||||
"mediaStreamCreated",
|
||||
"mediaStreamDestroyed",
|
||||
"remoteVideoStatus",
|
||||
"windowUnload"
|
||||
]);
|
||||
|
||||
this.setStoreState({
|
||||
apiKey: actionData.apiKey,
|
||||
callerId: actionData.callerId,
|
||||
callId: actionData.callId,
|
||||
callState: CALL_STATES.GATHER,
|
||||
callType: actionData.callType,
|
||||
contact: actionData.contact,
|
||||
outgoing: windowType === "outgoing",
|
||||
progressURL: actionData.progressURL,
|
||||
sessionId: actionData.sessionId,
|
||||
sessionToken: actionData.sessionToken,
|
||||
videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY,
|
||||
websocketToken: actionData.websocketToken,
|
||||
windowId: actionData.windowId
|
||||
});
|
||||
|
||||
if (this.getStoreState("outgoing")) {
|
||||
this._setupOutgoingCall();
|
||||
} else {
|
||||
this._setupIncomingCall();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles starting a call connection - connecting the session on the
|
||||
* sdk, and setting the state appropriately.
|
||||
*/
|
||||
_startCallConnection: function() {
|
||||
var state = this.getStoreState();
|
||||
|
||||
this.sdkDriver.connectSession({
|
||||
apiKey: state.apiKey,
|
||||
sessionId: state.sessionId,
|
||||
sessionToken: state.sessionToken,
|
||||
sendTwoWayMediaTelemetry: state.outgoing // only one side of the call
|
||||
});
|
||||
this.mozLoop.addConversationContext(
|
||||
state.windowId,
|
||||
state.sessionId,
|
||||
state.callId);
|
||||
this.setStoreState({callState: CALL_STATES.ONGOING});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
|
||||
this._websocket.accept();
|
||||
|
||||
this._startCallConnection();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* server of progress.
|
||||
*
|
||||
* @param {sharedActions.ConnectCall} actionData The action data.
|
||||
*/
|
||||
connectCall: function(actionData) {
|
||||
this.setStoreState(actionData.sessionData);
|
||||
this._connectWebSocket();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hangs up an ongoing call.
|
||||
*/
|
||||
hangupCall: function() {
|
||||
if (this._websocket) {
|
||||
// Let the server know the user has hung up.
|
||||
this._websocket.mediaFail();
|
||||
}
|
||||
|
||||
this._endSession();
|
||||
this.setStoreState({
|
||||
callState: this._storeState.callState === CALL_STATES.ONGOING ?
|
||||
CALL_STATES.FINISHED : CALL_STATES.CLOSE
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The remote peer disconnected from the session.
|
||||
*
|
||||
* @param {sharedActions.RemotePeerDisconnected} actionData
|
||||
*/
|
||||
remotePeerDisconnected: function(actionData) {
|
||||
this._endSession();
|
||||
|
||||
// If the peer hungup, we end normally, otherwise
|
||||
// we treat this as a call failure.
|
||||
if (actionData.peerHungup) {
|
||||
this.setStoreState({callState: CALL_STATES.FINISHED});
|
||||
} else {
|
||||
this.setStoreState({
|
||||
callState: CALL_STATES.TERMINATED,
|
||||
callStateReason: "peerNetworkDisconnected"
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancels a call. This can happen for incoming or outgoing calls.
|
||||
* Although the user doesn't "cancel" an incoming call, it may be that
|
||||
* the remote peer cancelled theirs before the incoming call was accepted.
|
||||
*/
|
||||
cancelCall: function() {
|
||||
if (this.getStoreState("outgoing")) {
|
||||
var callState = this.getStoreState("callState");
|
||||
if (this._websocket &&
|
||||
(callState === CALL_STATES.CONNECTING ||
|
||||
callState === CALL_STATES.ALERTING)) {
|
||||
// Let the server know the user has hung up.
|
||||
this._websocket.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
this._endSession();
|
||||
this.setStoreState({callState: CALL_STATES.CLOSE});
|
||||
},
|
||||
|
||||
/**
|
||||
* Retries a call
|
||||
*/
|
||||
retryCall: function() {
|
||||
var callState = this.getStoreState("callState");
|
||||
if (callState !== CALL_STATES.TERMINATED) {
|
||||
console.error("Unexpected retry in state", callState);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStoreState({callState: CALL_STATES.GATHER});
|
||||
if (this.getStoreState("outgoing")) {
|
||||
this._setupOutgoingCall();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies that all media is now connected
|
||||
*/
|
||||
mediaConnected: function() {
|
||||
this._websocket.mediaUp();
|
||||
this.setStoreState({mediaConnected: true});
|
||||
},
|
||||
|
||||
/**
|
||||
* Records the mute state for the stream.
|
||||
*
|
||||
* @param {sharedActions.setMute} actionData The mute state for the stream type.
|
||||
*/
|
||||
setMute: function(actionData) {
|
||||
var newState = {};
|
||||
newState[actionData.type + "Muted"] = !actionData.enabled;
|
||||
this.setStoreState(newState);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches a new room URL intended to be sent over email when a contact
|
||||
* can't be reached.
|
||||
*/
|
||||
fetchRoomEmailLink: function(actionData) {
|
||||
this.mozLoop.rooms.create({
|
||||
decryptedContext: {
|
||||
roomName: actionData.roomName
|
||||
},
|
||||
maxSize: loop.store.MAX_ROOM_CREATION_SIZE,
|
||||
expiresIn: loop.store.DEFAULT_EXPIRES_IN
|
||||
}, function(err, createdRoomData) {
|
||||
var buckets = this.mozLoop.ROOM_CREATE;
|
||||
if (err) {
|
||||
this.trigger("error:emailLink");
|
||||
this.mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_FAIL);
|
||||
return;
|
||||
}
|
||||
this.setStoreState({"emailLink": createdRoomData.roomUrl});
|
||||
this.mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_SUCCESS);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles a media stream being created. This may be a local or a remote stream.
|
||||
*
|
||||
* @param {sharedActions.MediaStreamCreated} actionData
|
||||
*/
|
||||
mediaStreamCreated: function(actionData) {
|
||||
if (actionData.isLocal) {
|
||||
this.setStoreState({
|
||||
localVideoEnabled: actionData.hasVideo,
|
||||
localSrcMediaElement: actionData.srcMediaElement
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStoreState({
|
||||
remoteVideoEnabled: actionData.hasVideo,
|
||||
remoteSrcMediaElement: actionData.srcMediaElement
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles a media stream being destroyed. This may be a local or a remote stream.
|
||||
*
|
||||
* @param {sharedActions.MediaStreamDestroyed} actionData
|
||||
*/
|
||||
mediaStreamDestroyed: function(actionData) {
|
||||
if (actionData.isLocal) {
|
||||
this.setStoreState({
|
||||
localSrcMediaElement: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStoreState({
|
||||
remoteSrcMediaElement: null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles a remote stream having video enabled or disabled.
|
||||
*
|
||||
* @param {sharedActions.RemoteVideoStatus} actionData
|
||||
*/
|
||||
remoteVideoStatus: function(actionData) {
|
||||
this.setStoreState({
|
||||
remoteVideoEnabled: actionData.videoEnabled
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when the window is unloaded, either by code, or by the user
|
||||
* explicitly closing it. Expected to do any necessary housekeeping, such
|
||||
* as shutting down the call cleanly and adding any relevant telemetry data.
|
||||
*/
|
||||
windowUnload: function() {
|
||||
if (!this.getStoreState("outgoing") &&
|
||||
this.getStoreState("callState") === CALL_STATES.ALERTING &&
|
||||
this._websocket) {
|
||||
this._websocket.decline();
|
||||
}
|
||||
|
||||
this._endSession();
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets up an incoming call. All we really need to do here is
|
||||
* to connect the websocket, as we've already got all the information
|
||||
* when the window opened.
|
||||
*/
|
||||
_setupIncomingCall: function() {
|
||||
this._connectWebSocket();
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtains the outgoing call data from the server and handles the
|
||||
* result.
|
||||
*/
|
||||
_setupOutgoingCall: function() {
|
||||
var contactAddresses = [];
|
||||
var contact = this.getStoreState("contact");
|
||||
|
||||
this.mozLoop.calls.setCallInProgress(this.getStoreState("windowId"));
|
||||
|
||||
function appendContactValues(property, strip) {
|
||||
if (contact.hasOwnProperty(property)) {
|
||||
contact[property].forEach(function(item) {
|
||||
if (strip) {
|
||||
contactAddresses.push(item.value
|
||||
.replace(/^(\+)?(.*)$/g, function(m, prefix, number) {
|
||||
return (prefix || "") + number.replace(/[\D]+/g, "");
|
||||
}));
|
||||
} else {
|
||||
contactAddresses.push(item.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appendContactValues("email");
|
||||
appendContactValues("tel", true);
|
||||
|
||||
this.client.setupOutgoingCall(contactAddresses,
|
||||
this.getStoreState("callType"),
|
||||
function(err, result) {
|
||||
if (err) {
|
||||
console.error("Failed to get outgoing call data", err);
|
||||
var failureReason = FAILURE_DETAILS.UNKNOWN;
|
||||
if (err.errno === REST_ERRNOS.USER_UNAVAILABLE) {
|
||||
failureReason = FAILURE_DETAILS.USER_UNAVAILABLE;
|
||||
}
|
||||
this.dispatcher.dispatch(
|
||||
new sharedActions.ConnectionFailure({reason: failureReason}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Success, dispatch a new action.
|
||||
this.dispatcher.dispatch(
|
||||
new sharedActions.ConnectCall({sessionData: result}));
|
||||
}.bind(this)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets up and connects the websocket to the server. The websocket
|
||||
* deals with sending and obtaining status via the server about the
|
||||
* setup of the call.
|
||||
*/
|
||||
_connectWebSocket: function() {
|
||||
this._websocket = new loop.CallConnectionWebSocket({
|
||||
url: this.getStoreState("progressURL"),
|
||||
callId: this.getStoreState("callId"),
|
||||
websocketToken: this.getStoreState("websocketToken")
|
||||
});
|
||||
|
||||
this._websocket.promiseConnect().then(
|
||||
function(progressState) {
|
||||
this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
|
||||
// This is the websocket call state, i.e. waiting for the
|
||||
// other end to connect to the server.
|
||||
wsState: progressState
|
||||
}));
|
||||
}.bind(this),
|
||||
function(error) {
|
||||
console.error("Websocket failed to connect", error);
|
||||
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
|
||||
reason: "websocket-setup"
|
||||
}));
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
this.listenTo(this._websocket, "progress", this._handleWebSocketProgress);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures the session is ended and the websocket is disconnected.
|
||||
*/
|
||||
_endSession: function(nextState) {
|
||||
this.sdkDriver.disconnectSession();
|
||||
if (this._websocket) {
|
||||
this.stopListening(this._websocket);
|
||||
|
||||
// Now close the websocket.
|
||||
this._websocket.close();
|
||||
delete this._websocket;
|
||||
}
|
||||
|
||||
this.mozLoop.calls.clearCallInProgress(
|
||||
this.getStoreState("windowId"));
|
||||
},
|
||||
|
||||
/**
|
||||
* If we hit any of the termination reasons, and the user hasn't accepted
|
||||
* then it seems reasonable to close the window/abort the incoming call.
|
||||
*
|
||||
* If the user has accepted the call, and something's happened, display
|
||||
* the call failed view.
|
||||
*
|
||||
* https://wiki.mozilla.org/Loop/Architecture/MVP#Termination_Reasons
|
||||
*
|
||||
* For outgoing calls, we treat all terminations as failures.
|
||||
*
|
||||
* @param {Object} progressData The progress data received from the websocket.
|
||||
* @param {String} previousState The previous state the websocket was in.
|
||||
*/
|
||||
_handleWebSocketStateTerminated: function(progressData, previousState) {
|
||||
if (this.getStoreState("outgoing") ||
|
||||
(previousState !== WS_STATES.INIT &&
|
||||
previousState !== WS_STATES.ALERTING)) {
|
||||
// For outgoing calls we can treat everything as connection failure.
|
||||
|
||||
// XXX We currently fallback to the websocket reason, but really these should
|
||||
// be fully migrated to use FAILURE_DETAILS, so as not to expose websocket
|
||||
// states outside of this store. Bug 1124384 should help to fix this.
|
||||
var reason = progressData.reason;
|
||||
|
||||
if (reason === WEBSOCKET_REASONS.REJECT ||
|
||||
reason === WEBSOCKET_REASONS.BUSY) {
|
||||
reason = FAILURE_DETAILS.USER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
|
||||
reason: reason
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatcher.dispatch(new sharedActions.CancelCall());
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to handle any progressed received from the websocket. This will
|
||||
* dispatch new actions so that the data can be handled appropriately.
|
||||
*
|
||||
* @param {Object} progressData The progress data received from the websocket.
|
||||
* @param {String} previousState The previous state the websocket was in.
|
||||
*/
|
||||
_handleWebSocketProgress: function(progressData, previousState) {
|
||||
switch(progressData.state) {
|
||||
case WS_STATES.TERMINATED: {
|
||||
this._handleWebSocketStateTerminated(progressData, previousState);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
|
||||
wsState: progressData.state
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
@ -1,299 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.CallConnectionWebSocket = (function() {
|
||||
"use strict";
|
||||
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
|
||||
// Response timeout is 5 seconds as per API.
|
||||
var kResponseTimeout = 5000;
|
||||
|
||||
/**
|
||||
* Handles a websocket specifically for a call connection.
|
||||
*
|
||||
* There should be one of these created for each call connection.
|
||||
*
|
||||
* options items:
|
||||
* - url The url of the websocket to connect to.
|
||||
* - callId The call id for the call
|
||||
* - websocketToken The authentication token for the websocket
|
||||
*
|
||||
* @param {Object} options The options for this websocket.
|
||||
*/
|
||||
function CallConnectionWebSocket(options) {
|
||||
this.options = options || {};
|
||||
|
||||
if (!this.options.url) {
|
||||
throw new Error("No url in options");
|
||||
}
|
||||
if (!this.options.callId) {
|
||||
throw new Error("No callId in options");
|
||||
}
|
||||
if (!this.options.websocketToken) {
|
||||
throw new Error("No websocketToken in options");
|
||||
}
|
||||
|
||||
this._lastServerState = "init";
|
||||
|
||||
// Set loop.debug.sdk to true in the browser, or standalone:
|
||||
// localStorage.setItem("debug.websocket", true);
|
||||
this._debugWebSocket =
|
||||
loop.shared.utils.getBoolPreference("debug.websocket");
|
||||
|
||||
_.extend(this, Backbone.Events);
|
||||
}
|
||||
|
||||
CallConnectionWebSocket.prototype = {
|
||||
/**
|
||||
* Start the connection to the websocket.
|
||||
*
|
||||
* @return {Promise} A promise that resolves when the websocket
|
||||
* server connection is open and "hello"s have been
|
||||
* exchanged. It is rejected if there is a failure in
|
||||
* connection or the initial exchange of "hello"s.
|
||||
*/
|
||||
promiseConnect: function() {
|
||||
var promise = new Promise(
|
||||
function(resolve, reject) {
|
||||
this.socket = new WebSocket(this.options.url);
|
||||
this.socket.onopen = this._onopen.bind(this);
|
||||
this.socket.onmessage = this._onmessage.bind(this);
|
||||
this.socket.onerror = this._onerror.bind(this);
|
||||
this.socket.onclose = this._onclose.bind(this);
|
||||
|
||||
var timeout = setTimeout(function() {
|
||||
if (this.connectDetails && this.connectDetails.reject) {
|
||||
this.connectDetails.reject(WEBSOCKET_REASONS.TIMEOUT);
|
||||
this._clearConnectionFlags();
|
||||
}
|
||||
}.bind(this), kResponseTimeout);
|
||||
this.connectDetails = {
|
||||
resolve: resolve,
|
||||
reject: reject,
|
||||
timeout: timeout
|
||||
};
|
||||
}.bind(this));
|
||||
|
||||
return promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Closes the websocket. This shouldn't be the normal action as the server
|
||||
* will normally close the socket. Only in bad error cases, or where we need
|
||||
* to close the socket just before closing the window (to avoid an error)
|
||||
* should we call this.
|
||||
*/
|
||||
close: function() {
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
}
|
||||
},
|
||||
|
||||
_clearConnectionFlags: function() {
|
||||
clearTimeout(this.connectDetails.timeout);
|
||||
delete this.connectDetails;
|
||||
},
|
||||
|
||||
/**
|
||||
* Internal function called to resolve the connection promise.
|
||||
*
|
||||
* It will log an error if no promise is found.
|
||||
*
|
||||
* @param {String} progressState The current state of progress of the
|
||||
* websocket.
|
||||
*/
|
||||
_completeConnection: function(progressState) {
|
||||
if (this.connectDetails && this.connectDetails.resolve) {
|
||||
this.connectDetails.resolve(progressState);
|
||||
this._clearConnectionFlags();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to complete connection promise - no promise available");
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the websocket is connecting, and rejects the connection
|
||||
* promise if appropriate.
|
||||
*
|
||||
* @param {Object} event The event to reject the promise with if
|
||||
* appropriate.
|
||||
*/
|
||||
_checkConnectionFailed: function(event) {
|
||||
if (this.connectDetails && this.connectDetails.reject) {
|
||||
this.connectDetails.reject(event);
|
||||
this._clearConnectionFlags();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies the server that the user has declined the call.
|
||||
*/
|
||||
decline: function() {
|
||||
this._send({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.REJECT
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies the server that the user has accepted the call.
|
||||
*/
|
||||
accept: function() {
|
||||
this._send({
|
||||
messageType: "action",
|
||||
event: "accept"
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies the server that the outgoing media is up, and the
|
||||
* incoming media is being received.
|
||||
*/
|
||||
mediaUp: function() {
|
||||
this._send({
|
||||
messageType: "action",
|
||||
event: "media-up"
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies the server that the outgoing call is cancelled by the
|
||||
* user.
|
||||
*/
|
||||
cancel: function() {
|
||||
this._send({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.CANCEL
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifies the server that something failed during setup.
|
||||
*/
|
||||
mediaFail: function() {
|
||||
this._send({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.MEDIA_FAIL
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends data on the websocket.
|
||||
*
|
||||
* @param {Object} data The data to send.
|
||||
*/
|
||||
_send: function(data) {
|
||||
this._log("WS Sending", data);
|
||||
|
||||
this.socket.send(JSON.stringify(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to determine if the server state is in a completed state, i.e.
|
||||
* the server has determined the connection is terminated or connected.
|
||||
*
|
||||
* @return True if the last received state is terminated or connected.
|
||||
*/
|
||||
get _stateIsCompleted() {
|
||||
return this._lastServerState === "terminated" ||
|
||||
this._lastServerState === "connected";
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when the socket is open. Automatically sends a "hello"
|
||||
* message to the server.
|
||||
*/
|
||||
_onopen: function() {
|
||||
// Auto-register with the server.
|
||||
this._send({
|
||||
messageType: "hello",
|
||||
callId: this.options.callId,
|
||||
auth: this.options.websocketToken
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a message is received from the server.
|
||||
*
|
||||
* @param {Object} event The websocket onmessage event.
|
||||
*/
|
||||
_onmessage: function(event) {
|
||||
var msgData;
|
||||
try {
|
||||
msgData = JSON.parse(event.data);
|
||||
} catch (x) {
|
||||
console.error("Error parsing received message:", x);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log("WS Receiving", event.data);
|
||||
|
||||
var previousState = this._lastServerState;
|
||||
this._lastServerState = msgData.state;
|
||||
|
||||
switch(msgData.messageType) {
|
||||
case "hello":
|
||||
this._completeConnection(msgData.state);
|
||||
break;
|
||||
case "progress":
|
||||
this.trigger("progress:" + msgData.state);
|
||||
this.trigger("progress", msgData, previousState);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when there is an error on the websocket.
|
||||
*
|
||||
* @param {Object} event A simple error event.
|
||||
*/
|
||||
_onerror: function(event) {
|
||||
this._log("WS Error", event);
|
||||
|
||||
if (!this._stateIsCompleted &&
|
||||
!this._checkConnectionFailed(event)) {
|
||||
this.trigger("error", event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when the websocket is closed.
|
||||
*
|
||||
* @param {CloseEvent} event The details of the websocket closing.
|
||||
*/
|
||||
_onclose: function(event) {
|
||||
this._log("WS Close", event);
|
||||
|
||||
// If the websocket goes away when we're not in a completed state
|
||||
// then its an error. So we either pass it back via the connection
|
||||
// promise, or trigger the closed event.
|
||||
if (!this._stateIsCompleted &&
|
||||
!this._checkConnectionFailed(event)) {
|
||||
this.trigger("closed", event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logs debug to the console.
|
||||
*
|
||||
* Parameters: same as console.log
|
||||
*/
|
||||
_log: function() {
|
||||
if (this._debugWebSocket) {
|
||||
console.log.apply(console, arguments);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CallConnectionWebSocket;
|
||||
})();
|
@ -1,133 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
describe("loop.Client", function() {
|
||||
"use strict";
|
||||
|
||||
var expect = chai.expect;
|
||||
var sandbox,
|
||||
callback,
|
||||
client,
|
||||
mozLoop,
|
||||
fakeToken,
|
||||
hawkRequestStub;
|
||||
|
||||
var fakeErrorRes = {
|
||||
code: 400,
|
||||
errno: 400,
|
||||
error: "Request Failed",
|
||||
message: "invalid token"
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
sandbox = sinon.sandbox.create();
|
||||
callback = sinon.spy();
|
||||
fakeToken = "fakeTokenText";
|
||||
mozLoop = {
|
||||
getLoopPref: sandbox.stub()
|
||||
.returns(null)
|
||||
.withArgs("hawk-session-token")
|
||||
.returns(fakeToken),
|
||||
hawkRequest: sinon.stub(),
|
||||
LOOP_SESSION_TYPE: {
|
||||
GUEST: 1,
|
||||
FXA: 2
|
||||
},
|
||||
userProfile: null,
|
||||
telemetryAdd: sinon.spy()
|
||||
};
|
||||
// Alias for clearer tests.
|
||||
hawkRequestStub = mozLoop.hawkRequest;
|
||||
client = new loop.Client({
|
||||
mozLoop: mozLoop
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe("loop.Client", function() {
|
||||
describe("#setupOutgoingCall", function() {
|
||||
var calleeIds, callType;
|
||||
|
||||
beforeEach(function() {
|
||||
calleeIds = [
|
||||
"fakeemail", "fake phone"
|
||||
];
|
||||
callType = "audio";
|
||||
});
|
||||
|
||||
it("should make a POST call to /calls", function() {
|
||||
client.setupOutgoingCall(calleeIds, callType);
|
||||
|
||||
sinon.assert.calledOnce(hawkRequestStub);
|
||||
sinon.assert.calledWith(hawkRequestStub,
|
||||
mozLoop.LOOP_SESSION_TYPE.FXA,
|
||||
"/calls",
|
||||
"POST",
|
||||
{ calleeId: calleeIds, callType: callType, channel: "unknown" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the channel when defined", function() {
|
||||
mozLoop.appVersionInfo = {
|
||||
channel: "beta"
|
||||
};
|
||||
|
||||
client.setupOutgoingCall(calleeIds, callType);
|
||||
|
||||
sinon.assert.calledOnce(hawkRequestStub);
|
||||
sinon.assert.calledWith(hawkRequestStub,
|
||||
mozLoop.LOOP_SESSION_TYPE.FXA,
|
||||
"/calls",
|
||||
"POST",
|
||||
{ calleeId: calleeIds, callType: callType, channel: "beta" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the callback if the request is successful", function() {
|
||||
var requestData = {
|
||||
apiKey: "fake",
|
||||
callId: "fakeCall",
|
||||
progressURL: "fakeurl",
|
||||
sessionId: "12345678",
|
||||
sessionToken: "15263748",
|
||||
websocketToken: "13572468"
|
||||
};
|
||||
|
||||
hawkRequestStub.callsArgWith(4, null, JSON.stringify(requestData));
|
||||
|
||||
client.setupOutgoingCall(calleeIds, callType, callback);
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
sinon.assert.calledWithExactly(callback, null, requestData);
|
||||
});
|
||||
|
||||
it("should send an error when the request fails", function() {
|
||||
hawkRequestStub.callsArgWith(4, fakeErrorRes);
|
||||
|
||||
client.setupOutgoingCall(calleeIds, callType, callback);
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
|
||||
return err.code === 400 && err.message === "invalid token";
|
||||
}));
|
||||
});
|
||||
|
||||
it("should send an error if the data is not valid", function() {
|
||||
// Sets up the hawkRequest stub to trigger the callback with
|
||||
// an error
|
||||
hawkRequestStub.callsArgWith(4, null, "{}");
|
||||
|
||||
client.setupOutgoingCall(calleeIds, callType, callback);
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
|
||||
return /Invalid data received/.test(err.message);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,965 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
describe("loop.conversationViews", function () {
|
||||
"use strict";
|
||||
|
||||
var expect = chai.expect;
|
||||
var TestUtils = React.addons.TestUtils;
|
||||
var sharedActions = loop.shared.actions;
|
||||
var sharedUtils = loop.shared.utils;
|
||||
var sharedViews = loop.shared.views;
|
||||
var sandbox, view, dispatcher, contact, fakeAudioXHR, conversationStore;
|
||||
var fakeMozLoop, fakeWindow, fakeClock;
|
||||
|
||||
var CALL_STATES = loop.store.CALL_STATES;
|
||||
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
|
||||
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
|
||||
var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
|
||||
beforeEach(function() {
|
||||
sandbox = sinon.sandbox.create();
|
||||
fakeClock = sandbox.useFakeTimers();
|
||||
|
||||
sandbox.stub(document.mozL10n, "get", function(x) {
|
||||
return x;
|
||||
});
|
||||
|
||||
dispatcher = new loop.Dispatcher();
|
||||
sandbox.stub(dispatcher, "dispatch");
|
||||
|
||||
contact = {
|
||||
name: [ "mrsmith" ],
|
||||
email: [{
|
||||
type: "home",
|
||||
value: "fakeEmail",
|
||||
pref: true
|
||||
}]
|
||||
};
|
||||
fakeAudioXHR = {
|
||||
open: sinon.spy(),
|
||||
send: function() {},
|
||||
abort: function() {},
|
||||
getResponseHeader: function(header) {
|
||||
if (header === "Content-Type") {
|
||||
return "audio/ogg";
|
||||
}
|
||||
},
|
||||
responseType: null,
|
||||
response: new ArrayBuffer(10),
|
||||
onload: null
|
||||
};
|
||||
|
||||
fakeMozLoop = navigator.mozLoop = {
|
||||
SHARING_ROOM_URL: {
|
||||
EMAIL_FROM_CALLFAILED: 2,
|
||||
EMAIL_FROM_CONVERSATION: 3
|
||||
},
|
||||
// Dummy function, stubbed below.
|
||||
getLoopPref: function() {},
|
||||
setLoopPref: sandbox.stub(),
|
||||
calls: {
|
||||
clearCallInProgress: sinon.stub()
|
||||
},
|
||||
composeEmail: sinon.spy(),
|
||||
get appVersionInfo() {
|
||||
return {
|
||||
version: "42",
|
||||
channel: "test",
|
||||
platform: "test"
|
||||
};
|
||||
},
|
||||
getAudioBlob: sinon.spy(function(name, callback) {
|
||||
callback(null, new Blob([new ArrayBuffer(10)], {type: "audio/ogg"}));
|
||||
}),
|
||||
startAlerting: sinon.stub(),
|
||||
stopAlerting: sinon.stub(),
|
||||
userProfile: {
|
||||
email: "bob@invalid.tld"
|
||||
}
|
||||
};
|
||||
sinon.stub(fakeMozLoop, "getLoopPref", function(pref) {
|
||||
if (pref === "fake") {
|
||||
return "http://fakeurl";
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
fakeWindow = {
|
||||
navigator: { mozLoop: fakeMozLoop },
|
||||
close: sinon.stub(),
|
||||
document: {},
|
||||
addEventListener: function() {},
|
||||
removeEventListener: function() {}
|
||||
};
|
||||
loop.shared.mixins.setRootObject(fakeWindow);
|
||||
|
||||
conversationStore = new loop.store.ConversationStore(dispatcher, {
|
||||
client: {},
|
||||
mozLoop: fakeMozLoop,
|
||||
sdkDriver: {}
|
||||
});
|
||||
|
||||
var textChatStore = new loop.store.TextChatStore(dispatcher, {
|
||||
sdkDriver: {}
|
||||
});
|
||||
|
||||
loop.store.StoreMixin.register({
|
||||
conversationStore: conversationStore,
|
||||
textChatStore: textChatStore
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
loop.shared.mixins.setRootObject(window);
|
||||
view = undefined;
|
||||
delete navigator.mozLoop;
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe("CallIdentifierView", function() {
|
||||
function mountTestComponent(props) {
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.CallIdentifierView, props));
|
||||
}
|
||||
|
||||
it("should set display the peer identifer", function() {
|
||||
view = mountTestComponent({
|
||||
showIcons: false,
|
||||
peerIdentifier: "mrssmith"
|
||||
});
|
||||
|
||||
expect(TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "fx-embedded-call-identifier-text").props.children).eql("mrssmith");
|
||||
});
|
||||
|
||||
it("should not display the icons if showIcons is false", function() {
|
||||
view = mountTestComponent({
|
||||
showIcons: false,
|
||||
peerIdentifier: "mrssmith"
|
||||
});
|
||||
|
||||
expect(TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "fx-embedded-call-detail").props.className).to.contain("hide");
|
||||
});
|
||||
|
||||
it("should display the icons if showIcons is true", function() {
|
||||
view = mountTestComponent({
|
||||
showIcons: true,
|
||||
peerIdentifier: "mrssmith"
|
||||
});
|
||||
|
||||
expect(TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "fx-embedded-call-detail").props.className).to.not.contain("hide");
|
||||
});
|
||||
|
||||
it("should display the url timestamp", function() {
|
||||
sandbox.stub(loop.shared.utils, "formatDate").returns(("October 9, 2014"));
|
||||
|
||||
view = mountTestComponent({
|
||||
showIcons: true,
|
||||
peerIdentifier: "mrssmith",
|
||||
urlCreationDate: (new Date() / 1000).toString()
|
||||
});
|
||||
|
||||
expect(TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "fx-embedded-conversation-timestamp").props.children).eql("(October 9, 2014)");
|
||||
});
|
||||
|
||||
it("should show video as muted if video is false", function() {
|
||||
view = mountTestComponent({
|
||||
showIcons: true,
|
||||
peerIdentifier: "mrssmith",
|
||||
video: false
|
||||
});
|
||||
|
||||
expect(TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "fx-embedded-tiny-video-icon").props.className).to.contain("muted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PendingConversationView", function() {
|
||||
function mountTestComponent(props) {
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.PendingConversationView, props));
|
||||
}
|
||||
|
||||
it("should set display connecting string when the state is not alerting",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.CONNECTING,
|
||||
contact: contact,
|
||||
dispatcher: dispatcher
|
||||
});
|
||||
|
||||
var label = TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "btn-label").props.children;
|
||||
|
||||
expect(label).to.have.string("connecting");
|
||||
});
|
||||
|
||||
it("should set display ringing string when the state is alerting",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.ALERTING,
|
||||
contact: contact,
|
||||
dispatcher: dispatcher
|
||||
});
|
||||
|
||||
var label = TestUtils.findRenderedDOMComponentWithClass(
|
||||
view, "btn-label").props.children;
|
||||
|
||||
expect(label).to.have.string("ringing");
|
||||
});
|
||||
|
||||
it("should disable the cancel button if enableCancelButton is false",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.CONNECTING,
|
||||
contact: contact,
|
||||
dispatcher: dispatcher,
|
||||
enableCancelButton: false
|
||||
});
|
||||
|
||||
var cancelBtn = view.getDOMNode().querySelector(".btn-cancel");
|
||||
|
||||
expect(cancelBtn.classList.contains("disabled")).eql(true);
|
||||
});
|
||||
|
||||
it("should enable the cancel button if enableCancelButton is false",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.CONNECTING,
|
||||
contact: contact,
|
||||
dispatcher: dispatcher,
|
||||
enableCancelButton: true
|
||||
});
|
||||
|
||||
var cancelBtn = view.getDOMNode().querySelector(".btn-cancel");
|
||||
|
||||
expect(cancelBtn.classList.contains("disabled")).eql(false);
|
||||
});
|
||||
|
||||
it("should dispatch a cancelCall action when the cancel button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.CONNECTING,
|
||||
contact: contact,
|
||||
dispatcher: dispatcher
|
||||
});
|
||||
|
||||
var cancelBtn = view.getDOMNode().querySelector(".btn-cancel");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(cancelBtn);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "cancelCall"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FailureInfoView", function() {
|
||||
function mountTestComponent(options) {
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.FailureInfoView, options));
|
||||
}
|
||||
|
||||
it("should display a generic failure message by default", function() {
|
||||
view = mountTestComponent({
|
||||
failureReason: "fake"
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("generic_failure_message");
|
||||
});
|
||||
|
||||
it("should display a no media message for the no media reason", function() {
|
||||
view = mountTestComponent({
|
||||
failureReason: FAILURE_DETAILS.NO_MEDIA
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("no_media_failure_message");
|
||||
});
|
||||
|
||||
it("should display a no media message for the unable to publish reason", function() {
|
||||
view = mountTestComponent({
|
||||
failureReason: FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("no_media_failure_message");
|
||||
});
|
||||
|
||||
it("should display a user unavailable message for the unavailable reason", function() {
|
||||
view = mountTestComponent({
|
||||
contact: {email: [{value: "test@test.tld"}]},
|
||||
failureReason: FAILURE_DETAILS.USER_UNAVAILABLE
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("contact_unavailable_title");
|
||||
});
|
||||
|
||||
it("should display a ToS failure message for the ToS failure reason", function() {
|
||||
view = mountTestComponent({
|
||||
failureReason: FAILURE_DETAILS.TOS_FAILURE
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("tos_failure_message");
|
||||
});
|
||||
|
||||
it("should display a generic unavailable message if the contact doesn't have a display name", function() {
|
||||
view = mountTestComponent({
|
||||
contact: {
|
||||
tel: [{"pref": true, type: "work", value: ""}]
|
||||
},
|
||||
failureReason: FAILURE_DETAILS.USER_UNAVAILABLE
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("generic_contact_unavailable_title");
|
||||
});
|
||||
|
||||
it("should display an extra message", function() {
|
||||
view = mountTestComponent({
|
||||
extraMessage: "Fake message",
|
||||
failureReason: FAILURE_DETAILS.UNKNOWN
|
||||
});
|
||||
|
||||
var extraMessage = view.getDOMNode().querySelector(".failure-info-extra");
|
||||
|
||||
expect(extraMessage.textContent).eql("Fake message");
|
||||
});
|
||||
|
||||
it("should display an extra failure message", function() {
|
||||
view = mountTestComponent({
|
||||
extraFailureMessage: "Fake failure message",
|
||||
failureReason: FAILURE_DETAILS.UNKNOWN
|
||||
});
|
||||
|
||||
var extraFailureMessage = view.getDOMNode().querySelector(".failure-info-extra-failure");
|
||||
|
||||
expect(extraFailureMessage.textContent).eql("Fake failure message");
|
||||
});
|
||||
|
||||
it("should display an ICE failure message", function() {
|
||||
view = mountTestComponent({
|
||||
failureReason: FAILURE_DETAILS.ICE_FAILED
|
||||
});
|
||||
|
||||
var message = view.getDOMNode().querySelector(".failure-info-message");
|
||||
|
||||
expect(message.textContent).eql("ice_failure_message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DirectCallFailureView", function() {
|
||||
var fakeAudio, composeCallUrlEmail;
|
||||
|
||||
var fakeContact = {email: [{value: "test@test.tld"}]};
|
||||
|
||||
function mountTestComponent(options) {
|
||||
var props = _.extend({
|
||||
dispatcher: dispatcher,
|
||||
mozLoop: fakeMozLoop,
|
||||
outgoing: true
|
||||
}, options);
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.DirectCallFailureView, props));
|
||||
}
|
||||
|
||||
beforeEach(function() {
|
||||
fakeAudio = {
|
||||
play: sinon.spy(),
|
||||
pause: sinon.spy(),
|
||||
removeAttribute: sinon.spy()
|
||||
};
|
||||
sandbox.stub(window, "Audio").returns(fakeAudio);
|
||||
composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
|
||||
conversationStore.setStoreState({
|
||||
callStateReason: FAILURE_DETAILS.UNKNOWN,
|
||||
contact: fakeContact
|
||||
});
|
||||
});
|
||||
|
||||
it("should not display the retry button for incoming calls", function() {
|
||||
view = mountTestComponent({outgoing: false});
|
||||
|
||||
var retryBtn = view.getDOMNode().querySelector(".btn-retry");
|
||||
|
||||
expect(retryBtn.classList.contains("hide")).eql(true);
|
||||
});
|
||||
|
||||
it("should not display the email button for incoming calls", function() {
|
||||
view = mountTestComponent({outgoing: false});
|
||||
|
||||
var retryBtn = view.getDOMNode().querySelector(".btn-email");
|
||||
|
||||
expect(retryBtn.classList.contains("hide")).eql(true);
|
||||
});
|
||||
|
||||
it("should dispatch a retryCall action when the retry button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
var retryBtn = view.getDOMNode().querySelector(".btn-retry");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(retryBtn);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "retryCall"));
|
||||
});
|
||||
|
||||
it("should dispatch a cancelCall action when the cancel button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
var cancelBtn = view.getDOMNode().querySelector(".btn-cancel");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(cancelBtn);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "cancelCall"));
|
||||
});
|
||||
|
||||
it("should dispatch a fetchRoomEmailLink action when the email button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
var emailLinkBtn = view.getDOMNode().querySelector(".btn-email");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(emailLinkBtn);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "fetchRoomEmailLink"));
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("roomName", "test@test.tld"));
|
||||
});
|
||||
|
||||
it("should name the created room using the contact name when available",
|
||||
function() {
|
||||
conversationStore.setStoreState({
|
||||
contact: {
|
||||
email: [{value: "test@test.tld"}],
|
||||
name: ["Mr Fake ContactName"]
|
||||
}
|
||||
});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
var emailLinkBtn = view.getDOMNode().querySelector(".btn-email");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(emailLinkBtn);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("roomName", "Mr Fake ContactName"));
|
||||
});
|
||||
|
||||
it("should disable the email link button once the action is dispatched",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
var emailLinkBtn = view.getDOMNode().querySelector(".btn-email");
|
||||
React.addons.TestUtils.Simulate.click(emailLinkBtn);
|
||||
|
||||
expect(view.getDOMNode().querySelector(".btn-email").disabled).eql(true);
|
||||
});
|
||||
|
||||
it("should compose an email once the email link is received", function() {
|
||||
view = mountTestComponent();
|
||||
conversationStore.setStoreState({emailLink: "http://fake.invalid/"});
|
||||
|
||||
sinon.assert.calledOnce(composeCallUrlEmail);
|
||||
sinon.assert.calledWithExactly(composeCallUrlEmail,
|
||||
"http://fake.invalid/", "test@test.tld", null, "callfailed");
|
||||
});
|
||||
|
||||
it("should close the conversation window once the email link is received",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
conversationStore.setStoreState({emailLink: "http://fake.invalid/"});
|
||||
|
||||
sinon.assert.calledOnce(fakeWindow.close);
|
||||
});
|
||||
|
||||
it("should display an error message in case email link retrieval failed",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
conversationStore.trigger("error:emailLink");
|
||||
|
||||
expect(view.getDOMNode().querySelector(".failure-info-extra-failure")).not.eql(null);
|
||||
});
|
||||
|
||||
it("should allow retrying to get a call url if it failed previously",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
conversationStore.trigger("error:emailLink");
|
||||
|
||||
expect(view.getDOMNode().querySelector(".btn-email").disabled).eql(false);
|
||||
});
|
||||
|
||||
it("should play a failure sound, once", function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
sinon.assert.calledOnce(navigator.mozLoop.getAudioBlob);
|
||||
sinon.assert.calledWithExactly(navigator.mozLoop.getAudioBlob,
|
||||
"failure", sinon.match.func);
|
||||
sinon.assert.calledOnce(fakeAudio.play);
|
||||
expect(fakeAudio.loop).to.equal(false);
|
||||
});
|
||||
|
||||
it("should display an additional message for outgoing calls", function() {
|
||||
view = mountTestComponent({
|
||||
outgoing: true
|
||||
});
|
||||
|
||||
var extraMessage = view.getDOMNode().querySelector(".failure-info-extra");
|
||||
|
||||
expect(extraMessage.textContent).eql("generic_failure_with_reason2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OngoingConversationView", function() {
|
||||
function mountTestComponent(extraProps) {
|
||||
var props = _.extend({
|
||||
chatWindowDetached: false,
|
||||
conversationStore: conversationStore,
|
||||
dispatcher: dispatcher,
|
||||
mozLoop: {},
|
||||
matchMedia: window.matchMedia
|
||||
}, extraProps);
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.OngoingConversationView, props));
|
||||
}
|
||||
|
||||
it("should dispatch a setupStreamElements action when the view is created",
|
||||
function() {
|
||||
view = mountTestComponent();
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "setupStreamElements"));
|
||||
});
|
||||
|
||||
it("should display the remote video when the stream is enabled", function() {
|
||||
conversationStore.setStoreState({
|
||||
remoteSrcMediaElement: { fake: 1 }
|
||||
});
|
||||
|
||||
view = mountTestComponent({
|
||||
mediaConnected: true,
|
||||
remoteVideoEnabled: true
|
||||
});
|
||||
|
||||
expect(view.getDOMNode().querySelector(".remote video")).not.eql(null);
|
||||
});
|
||||
|
||||
it("should display the local video when the stream is enabled", function() {
|
||||
conversationStore.setStoreState({
|
||||
localSrcMediaElement: { fake: 1 }
|
||||
});
|
||||
|
||||
view = mountTestComponent({
|
||||
video: {
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(view.getDOMNode().querySelector(".local video")).not.eql(null);
|
||||
});
|
||||
|
||||
it("should dispatch a setMute action when the audio mute button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
audio: {enabled: false}
|
||||
});
|
||||
|
||||
var muteBtn = view.getDOMNode().querySelector(".btn-mute-audio");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(muteBtn);
|
||||
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "setMute"));
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("enabled", true));
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("type", "audio"));
|
||||
});
|
||||
|
||||
it("should dispatch a setMute action when the video mute button is pressed",
|
||||
function() {
|
||||
view = mountTestComponent({
|
||||
video: {enabled: true}
|
||||
});
|
||||
|
||||
var muteBtn = view.getDOMNode().querySelector(".btn-mute-video");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(muteBtn);
|
||||
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("name", "setMute"));
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("enabled", false));
|
||||
sinon.assert.calledWithMatch(dispatcher.dispatch,
|
||||
sinon.match.hasOwn("type", "video"));
|
||||
});
|
||||
|
||||
it("should set the mute button as mute off", function() {
|
||||
view = mountTestComponent({
|
||||
video: {enabled: true}
|
||||
});
|
||||
|
||||
var muteBtn = view.getDOMNode().querySelector(".btn-mute-video");
|
||||
|
||||
expect(muteBtn.classList.contains("muted")).eql(false);
|
||||
});
|
||||
|
||||
it("should set the mute button as mute on", function() {
|
||||
view = mountTestComponent({
|
||||
audio: {enabled: false}
|
||||
});
|
||||
|
||||
var muteBtn = view.getDOMNode().querySelector(".btn-mute-audio");
|
||||
|
||||
expect(muteBtn.classList.contains("muted")).eql(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CallControllerView", function() {
|
||||
var onCallTerminatedStub;
|
||||
|
||||
function mountTestComponent() {
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.CallControllerView, {
|
||||
chatWindowDetached: false,
|
||||
dispatcher: dispatcher,
|
||||
mozLoop: fakeMozLoop,
|
||||
onCallTerminated: onCallTerminatedStub
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(function() {
|
||||
onCallTerminatedStub = sandbox.stub();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it("should set the document title to the callerId", function() {
|
||||
conversationStore.setStoreState({
|
||||
contact: contact
|
||||
});
|
||||
|
||||
mountTestComponent();
|
||||
|
||||
expect(fakeWindow.document.title).eql("mrsmith");
|
||||
});
|
||||
|
||||
it("should fallback to the contact email if the contact name is not defined", function() {
|
||||
delete contact.name;
|
||||
conversationStore.setStoreState({
|
||||
contact: contact
|
||||
});
|
||||
|
||||
mountTestComponent({contact: contact});
|
||||
|
||||
expect(fakeWindow.document.title).eql("fakeEmail");
|
||||
});
|
||||
|
||||
it("should fallback to the caller id if no contact is defined", function() {
|
||||
conversationStore.setStoreState({
|
||||
callerId: "fakeId"
|
||||
});
|
||||
|
||||
mountTestComponent({contact: contact});
|
||||
|
||||
expect(fakeWindow.document.title).eql("fakeId");
|
||||
});
|
||||
|
||||
it("should render the DirectCallFailureView when the call state is 'terminated'",
|
||||
function() {
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.TERMINATED,
|
||||
contact: contact,
|
||||
callStateReason: WEBSOCKET_REASONS.CLOSED,
|
||||
outgoing: true
|
||||
});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.DirectCallFailureView);
|
||||
});
|
||||
|
||||
it("should render the PendingConversationView for outgoing calls when the call state is 'gather'",
|
||||
function() {
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.GATHER,
|
||||
contact: contact,
|
||||
outgoing: true
|
||||
});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.PendingConversationView);
|
||||
});
|
||||
|
||||
it("should render the AcceptCallView for incoming calls when the call state is 'alerting'", function() {
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.ALERTING,
|
||||
outgoing: false,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.AcceptCallView);
|
||||
});
|
||||
|
||||
it("should render the OngoingConversationView when the call state is 'ongoing'",
|
||||
function() {
|
||||
conversationStore.setStoreState({callState: CALL_STATES.ONGOING});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.OngoingConversationView);
|
||||
});
|
||||
|
||||
it("should play the terminated sound when the call state is 'finished'",
|
||||
function() {
|
||||
var fakeAudio = {
|
||||
play: sinon.spy(),
|
||||
pause: sinon.spy(),
|
||||
removeAttribute: sinon.spy()
|
||||
};
|
||||
sandbox.stub(window, "Audio").returns(fakeAudio);
|
||||
|
||||
conversationStore.setStoreState({callState: CALL_STATES.FINISHED});
|
||||
|
||||
view = mountTestComponent();
|
||||
|
||||
sinon.assert.calledOnce(fakeAudio.play);
|
||||
});
|
||||
|
||||
it("should update the rendered views when the state is changed.",
|
||||
function() {
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.GATHER,
|
||||
contact: contact,
|
||||
outgoing: true
|
||||
});
|
||||
view = mountTestComponent();
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.PendingConversationView);
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.TERMINATED,
|
||||
callStateReason: WEBSOCKET_REASONS.CLOSED
|
||||
});
|
||||
TestUtils.findRenderedComponentWithType(view,
|
||||
loop.conversationViews.DirectCallFailureView);
|
||||
});
|
||||
|
||||
it("should call onCallTerminated when the call is finished", function() {
|
||||
conversationStore.setStoreState({
|
||||
callState: CALL_STATES.ONGOING
|
||||
});
|
||||
|
||||
view = mountTestComponent({
|
||||
callState: CALL_STATES.FINISHED
|
||||
});
|
||||
// Force a state change so that it triggers componentDidUpdate.
|
||||
view.setState({ callState: CALL_STATES.FINISHED });
|
||||
|
||||
sinon.assert.calledOnce(onCallTerminatedStub);
|
||||
sinon.assert.calledWithExactly(onCallTerminatedStub);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcceptCallView", function() {
|
||||
var callView;
|
||||
|
||||
function mountTestComponent(extraProps) {
|
||||
var props = _.extend({dispatcher: dispatcher, mozLoop: fakeMozLoop}, extraProps);
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(loop.conversationViews.AcceptCallView, props));
|
||||
}
|
||||
|
||||
afterEach(function() {
|
||||
callView = null;
|
||||
});
|
||||
|
||||
it("should start alerting on display", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
sinon.assert.calledOnce(fakeMozLoop.startAlerting);
|
||||
});
|
||||
|
||||
it("should stop alerting when removed from the display", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
callView.componentWillUnmount();
|
||||
|
||||
sinon.assert.calledOnce(fakeMozLoop.stopAlerting);
|
||||
});
|
||||
|
||||
describe("default answer mode", function() {
|
||||
it("should display video as primary answer mode", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var primaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-icon-video");
|
||||
|
||||
expect(primaryBtn).not.to.eql(null);
|
||||
});
|
||||
|
||||
it("should display audio as primary answer mode", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_ONLY,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var primaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-icon-audio");
|
||||
|
||||
expect(primaryBtn).not.to.eql(null);
|
||||
});
|
||||
|
||||
it("should accept call with video", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var primaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-icon-video");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(primaryBtn);
|
||||
|
||||
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() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_ONLY,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var primaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-icon-audio");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(primaryBtn);
|
||||
|
||||
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() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_ONLY,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var secondaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-video-small");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(secondaryBtn);
|
||||
|
||||
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() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var secondaryBtn = callView.getDOMNode()
|
||||
.querySelector(".fx-embedded-btn-audio-small");
|
||||
|
||||
React.addons.TestUtils.Simulate.click(secondaryBtn);
|
||||
|
||||
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 dispatch a DeclineCall action", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var buttonDecline = callView.getDOMNode().querySelector(".btn-decline");
|
||||
|
||||
TestUtils.Simulate.click(buttonDecline);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithExactly(dispatcher.dispatch,
|
||||
new sharedActions.DeclineCall({blockCaller: false}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("click event on .btn-block", function() {
|
||||
it("should dispatch a DeclineCall action with blockCaller true", function() {
|
||||
callView = mountTestComponent({
|
||||
callType: CALL_TYPES.AUDIO_VIDEO,
|
||||
callerId: "fake@invalid.com"
|
||||
});
|
||||
|
||||
var buttonBlock = callView.getDOMNode().querySelector(".btn-block");
|
||||
|
||||
TestUtils.Simulate.click(buttonBlock);
|
||||
|
||||
sinon.assert.calledOnce(dispatcher.dispatch);
|
||||
sinon.assert.calledWithExactly(dispatcher.dispatch,
|
||||
new sharedActions.DeclineCall({blockCaller: true}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
@ -1,340 +0,0 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
describe("loop.CallConnectionWebSocket", function() {
|
||||
"use strict";
|
||||
|
||||
var expect = chai.expect;
|
||||
var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
|
||||
|
||||
var sandbox,
|
||||
dummySocket;
|
||||
|
||||
beforeEach(function() {
|
||||
sandbox = sinon.sandbox.create();
|
||||
sandbox.useFakeTimers();
|
||||
|
||||
dummySocket = {
|
||||
close: sinon.spy(),
|
||||
send: sinon.spy()
|
||||
};
|
||||
sandbox.stub(window, "WebSocket").returns(dummySocket);
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe("#constructor", function() {
|
||||
it("should require a url option", function() {
|
||||
expect(function() {
|
||||
return new loop.CallConnectionWebSocket();
|
||||
}).to.Throw(/No url/);
|
||||
});
|
||||
|
||||
it("should require a callId setting", function() {
|
||||
expect(function() {
|
||||
return new loop.CallConnectionWebSocket({url: "wss://fake/"});
|
||||
}).to.Throw(/No callId/);
|
||||
});
|
||||
|
||||
it("should require a websocketToken setting", function() {
|
||||
expect(function() {
|
||||
return new loop.CallConnectionWebSocket({
|
||||
url: "http://fake/",
|
||||
callId: "hello"
|
||||
});
|
||||
}).to.Throw(/No websocketToken/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructed", function() {
|
||||
var callWebSocket, fakeUrl, fakeCallId, fakeWebSocketToken;
|
||||
|
||||
beforeEach(function() {
|
||||
fakeUrl = "wss://fake/";
|
||||
fakeCallId = "callId";
|
||||
fakeWebSocketToken = "7b";
|
||||
|
||||
callWebSocket = new loop.CallConnectionWebSocket({
|
||||
url: fakeUrl,
|
||||
callId: fakeCallId,
|
||||
websocketToken: fakeWebSocketToken
|
||||
});
|
||||
});
|
||||
|
||||
describe("#promiseConnect", function() {
|
||||
it("should create a new websocket connection", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
sinon.assert.calledOnce(window.WebSocket);
|
||||
sinon.assert.calledWithExactly(window.WebSocket, fakeUrl);
|
||||
});
|
||||
|
||||
it("should reject the promise if connection is not completed in " +
|
||||
"5 seconds", function(done) {
|
||||
var promise = callWebSocket.promiseConnect();
|
||||
|
||||
sandbox.clock.tick(5101);
|
||||
|
||||
promise.then(function() {}, function(error) {
|
||||
expect(error).to.be.equal(WEBSOCKET_REASONS.TIMEOUT);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject the promise if the connection errors", function(done) {
|
||||
var promise = callWebSocket.promiseConnect();
|
||||
|
||||
dummySocket.onerror("error");
|
||||
|
||||
promise.then(function() {}, function(error) {
|
||||
expect(error).to.be.equal("error");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject the promise if the connection closes", function(done) {
|
||||
var promise = callWebSocket.promiseConnect();
|
||||
|
||||
dummySocket.onclose("close");
|
||||
|
||||
promise.then(function() {}, function(error) {
|
||||
expect(error).to.be.equal("close");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send hello when the socket is opened", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
dummySocket.onopen();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "hello",
|
||||
callId: fakeCallId,
|
||||
auth: fakeWebSocketToken
|
||||
}));
|
||||
});
|
||||
|
||||
it("should resolve the promise when the 'hello' is received",
|
||||
function(done) {
|
||||
var promise = callWebSocket.promiseConnect();
|
||||
|
||||
dummySocket.onmessage({
|
||||
data: '{"messageType":"hello", "state":"init"}'
|
||||
});
|
||||
|
||||
promise.then(function(state) {
|
||||
expect(state).eql("init");
|
||||
done();
|
||||
}, function() {
|
||||
done(new Error("shouldn't have rejected the promise"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#close", function() {
|
||||
it("should close the socket", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.close();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.close);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#decline", function() {
|
||||
it("should send a terminate message to the server", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.decline();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.REJECT
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#accept", function() {
|
||||
it("should send an accept message to the server", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.accept();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "action",
|
||||
event: "accept"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#mediaUp", function() {
|
||||
it("should send a media-up message to the server", function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.mediaUp();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "action",
|
||||
event: "media-up"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#cancel", function() {
|
||||
it("should send a terminate message to the server with a reason of WEBSOCKET_REASONS.CANCEL",
|
||||
function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.cancel();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.CANCEL
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#mediaFail", function() {
|
||||
it("should send a terminate message to the server with a reason of WEBSOCKET_REASONS.MEDIA_FAIL",
|
||||
function() {
|
||||
callWebSocket.promiseConnect();
|
||||
|
||||
callWebSocket.mediaFail();
|
||||
|
||||
sinon.assert.calledOnce(dummySocket.send);
|
||||
sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
|
||||
messageType: "action",
|
||||
event: "terminate",
|
||||
reason: WEBSOCKET_REASONS.MEDIA_FAIL
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Events", function() {
|
||||
beforeEach(function() {
|
||||
sandbox.stub(callWebSocket, "trigger");
|
||||
|
||||
callWebSocket.promiseConnect();
|
||||
});
|
||||
|
||||
describe("Progress", function() {
|
||||
it("should trigger a progress event on the callWebSocket", function() {
|
||||
var eventData = {
|
||||
messageType: "progress",
|
||||
state: "terminate",
|
||||
reason: WEBSOCKET_REASONS.REJECT
|
||||
};
|
||||
|
||||
dummySocket.onmessage({
|
||||
data: JSON.stringify(eventData)
|
||||
});
|
||||
|
||||
sinon.assert.called(callWebSocket.trigger);
|
||||
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress",
|
||||
eventData, "init");
|
||||
});
|
||||
|
||||
it("should trigger a progress event with the previous state", function() {
|
||||
var previousEventData = {
|
||||
messageType: "progress",
|
||||
state: "alerting"
|
||||
};
|
||||
|
||||
// This first call is to set the previous state of the object
|
||||
// ready for the main test below.
|
||||
dummySocket.onmessage({
|
||||
data: JSON.stringify(previousEventData)
|
||||
});
|
||||
|
||||
var currentEventData = {
|
||||
messageType: "progress",
|
||||
state: "terminate",
|
||||
reason: WEBSOCKET_REASONS.REJECT
|
||||
};
|
||||
|
||||
dummySocket.onmessage({
|
||||
data: JSON.stringify(currentEventData)
|
||||
});
|
||||
|
||||
sinon.assert.called(callWebSocket.trigger);
|
||||
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress",
|
||||
currentEventData, "alerting");
|
||||
});
|
||||
|
||||
it("should trigger a progress:<state> event on the callWebSocket", function() {
|
||||
var eventData = {
|
||||
messageType: "progress",
|
||||
state: "terminate",
|
||||
reason: WEBSOCKET_REASONS.REJECT
|
||||
};
|
||||
|
||||
dummySocket.onmessage({
|
||||
data: JSON.stringify(eventData)
|
||||
});
|
||||
|
||||
sinon.assert.called(callWebSocket.trigger);
|
||||
sinon.assert.calledWithExactly(callWebSocket.trigger, "progress:terminate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error", function() {
|
||||
// Handled in constructed -> #promiseConnect:
|
||||
// should reject the promise if the connection errors
|
||||
|
||||
it("should trigger an error if state is not completed", function() {
|
||||
callWebSocket._clearConnectionFlags();
|
||||
|
||||
dummySocket.onerror("Error");
|
||||
|
||||
sinon.assert.calledOnce(callWebSocket.trigger);
|
||||
sinon.assert.calledWithExactly(callWebSocket.trigger,
|
||||
"error", "Error");
|
||||
});
|
||||
|
||||
it("should not trigger an error if state is completed", function() {
|
||||
callWebSocket._clearConnectionFlags();
|
||||
callWebSocket._lastServerState = "connected";
|
||||
|
||||
dummySocket.onerror("Error");
|
||||
|
||||
sinon.assert.notCalled(callWebSocket.trigger);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close", function() {
|
||||
// Handled in constructed -> #promiseConnect:
|
||||
// should reject the promise if the connection closes
|
||||
|
||||
it("should trigger a close event if state is not completed", function() {
|
||||
callWebSocket._clearConnectionFlags();
|
||||
|
||||
dummySocket.onclose("Error");
|
||||
|
||||
sinon.assert.calledOnce(callWebSocket.trigger);
|
||||
sinon.assert.calledWithExactly(callWebSocket.trigger,
|
||||
"closed", "Error");
|
||||
});
|
||||
|
||||
it("should not trigger an error if state is completed", function() {
|
||||
callWebSocket._clearConnectionFlags();
|
||||
callWebSocket._lastServerState = "terminated";
|
||||
|
||||
dummySocket.onclose("Error");
|
||||
|
||||
sinon.assert.notCalled(callWebSocket.trigger);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user