Bug 1212083 - Part 2. Remove the unused files previously associated with direct calls. r=mikedeboer

This commit is contained in:
Mark Banner 2015-10-09 12:14:44 +01:00
parent 24b18f5c05
commit 503a193e66
15 changed files with 0 additions and 5460 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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