Bug 990678 - Add ability to make audio only calls in Loop standalone and desktop. r=Standard8

This commit is contained in:
Andrei Oprea 2014-08-15 19:45:31 +01:00
Родитель e8f465a4f5
Коммит 0f09159ecf
18 изменённых файлов: 613 добавлений и 133 удалений

Просмотреть файл

@ -32,13 +32,13 @@ loop.conversation = (function(OT, mozL10n) {
},
componentDidMount: function() {
window.addEventListener('click', this.clickHandler);
window.addEventListener('blur', this._hideDeclineMenu);
window.addEventListener("click", this.clickHandler);
window.addEventListener("blur", this._hideDeclineMenu);
},
componentWillUnmount: function() {
window.removeEventListener('click', this.clickHandler);
window.removeEventListener('blur', this._hideDeclineMenu);
window.removeEventListener("click", this.clickHandler);
window.removeEventListener("blur", this._hideDeclineMenu);
},
clickHandler: function(e) {
@ -48,8 +48,11 @@ loop.conversation = (function(OT, mozL10n) {
}
},
_handleAccept: function() {
this.props.model.trigger("accept");
_handleAccept: function(callType) {
return () => {
this.props.model.set("selectedCallType", callType);
this.props.model.trigger("accept");
};
},
_handleDecline: function() {
@ -74,15 +77,15 @@ loop.conversation = (function(OT, mozL10n) {
render: function() {
/* jshint ignore:start */
var btnClassAccept = "btn btn-success btn-accept";
var btnClassAccept = "btn btn-success btn-accept call-audio-video";
var btnClassBlock = "btn btn-error btn-block";
var btnClassDecline = "btn btn-error btn-decline";
var conversationPanelClass = "incoming-call " +
loop.shared.utils.getTargetPlatform();
var cx = React.addons.classSet;
var declineDropdownMenuClasses = cx({
var dropdownMenuClassesDecline = cx({
"native-dropdown-menu": true,
"decline-block-menu": true,
"conversation-window-dropdown": true,
"visually-hidden": !this.state.showDeclineMenu
});
return (
@ -92,22 +95,36 @@ loop.conversation = (function(OT, mozL10n) {
React.DOM.div({className: "button-chevron-menu-group"},
React.DOM.div({className: "button-group-chevron"},
React.DOM.div({className: "button-group"},
React.DOM.button({className: btnClassDecline, onClick: this._handleDecline},
React.DOM.button({className: btnClassDecline,
onClick: this._handleDecline},
__("incoming_call_decline_button")
),
React.DOM.div({className: "btn-chevron",
onClick: this._toggleDeclineMenu}
onClick: this._toggleDeclineMenu}
)
),
React.DOM.ul({className: declineDropdownMenuClasses},
React.DOM.ul({className: dropdownMenuClassesDecline},
React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock},
__("incoming_call_decline_and_block_button")
)
)
)
),
React.DOM.button({className: btnClassAccept, onClick: this._handleAccept},
__("incoming_call_answer_button")
React.DOM.div({className: "button-chevron-menu-group"},
React.DOM.div({className: "button-group"},
React.DOM.button({className: btnClassAccept,
onClick: this._handleAccept("audio-video")},
__("incoming_call_answer_button")
),
React.DOM.div({className: "call-audio-only",
onClick: this._handleAccept("audio"),
title: __("incoming_call_answer_audio_only_tooltip")}
)
)
)
)
)
@ -181,9 +198,10 @@ loop.conversation = (function(OT, mozL10n) {
// We'll probably really want to be getting this data from the
// background worker on the desktop client.
// Bug 1032700 should fix this.
this._conversation.setSessionData(sessionData[0]);
this._conversation.setIncomingSessionData(sessionData[0]);
this.loadReactComponent(loop.conversation.IncomingCallView({
model: this._conversation
model: this._conversation,
video: {enabled: this._conversation.hasVideoStream("incoming")}
}));
});
},
@ -213,7 +231,7 @@ loop.conversation = (function(OT, mozL10n) {
*/
declineAndBlock: function() {
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var token = navigator.mozLoop.getLoopCharPref("loopToken");
this._client.deleteCallUrl(token, function(error) {
// 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
@ -235,10 +253,14 @@ loop.conversation = (function(OT, mozL10n) {
return;
}
var callType = this._conversation.get("selectedCallType");
var videoStream = callType === "audio" ? false : true;
/*jshint newcap:false*/
this.loadReactComponent(sharedViews.ConversationView({
sdk: OT,
model: this._conversation
model: this._conversation,
video: {enabled: videoStream}
}));
},

Просмотреть файл

@ -32,13 +32,13 @@ loop.conversation = (function(OT, mozL10n) {
},
componentDidMount: function() {
window.addEventListener('click', this.clickHandler);
window.addEventListener('blur', this._hideDeclineMenu);
window.addEventListener("click", this.clickHandler);
window.addEventListener("blur", this._hideDeclineMenu);
},
componentWillUnmount: function() {
window.removeEventListener('click', this.clickHandler);
window.removeEventListener('blur', this._hideDeclineMenu);
window.removeEventListener("click", this.clickHandler);
window.removeEventListener("blur", this._hideDeclineMenu);
},
clickHandler: function(e) {
@ -48,8 +48,11 @@ loop.conversation = (function(OT, mozL10n) {
}
},
_handleAccept: function() {
this.props.model.trigger("accept");
_handleAccept: function(callType) {
return () => {
this.props.model.set("selectedCallType", callType);
this.props.model.trigger("accept");
};
},
_handleDecline: function() {
@ -74,15 +77,15 @@ loop.conversation = (function(OT, mozL10n) {
render: function() {
/* jshint ignore:start */
var btnClassAccept = "btn btn-success btn-accept";
var btnClassAccept = "btn btn-success btn-accept call-audio-video";
var btnClassBlock = "btn btn-error btn-block";
var btnClassDecline = "btn btn-error btn-decline";
var conversationPanelClass = "incoming-call " +
loop.shared.utils.getTargetPlatform();
var cx = React.addons.classSet;
var declineDropdownMenuClasses = cx({
var dropdownMenuClassesDecline = cx({
"native-dropdown-menu": true,
"decline-block-menu": true,
"conversation-window-dropdown": true,
"visually-hidden": !this.state.showDeclineMenu
});
return (
@ -92,23 +95,37 @@ loop.conversation = (function(OT, mozL10n) {
<div className="button-chevron-menu-group">
<div className="button-group-chevron">
<div className="button-group">
<button className={btnClassDecline} onClick={this._handleDecline}>
<button className={btnClassDecline}
onClick={this._handleDecline}>
{__("incoming_call_decline_button")}
</button>
<div className="btn-chevron"
onClick={this._toggleDeclineMenu}>
onClick={this._toggleDeclineMenu}>
</div>
</div>
<ul className={declineDropdownMenuClasses}>
<ul className={dropdownMenuClassesDecline}>
<li className="btn-block" onClick={this._handleDeclineBlock}>
{__("incoming_call_decline_and_block_button")}
</li>
</ul>
</div>
</div>
<div className="button-chevron-menu-group">
<div className="button-group">
<button className={btnClassAccept}
onClick={this._handleAccept("audio-video")}>
{__("incoming_call_answer_button")}
</button>
<div className="call-audio-only"
onClick={this._handleAccept("audio")}
title={__("incoming_call_answer_audio_only_tooltip")} >
</div>
</div>
</div>
<button className={btnClassAccept} onClick={this._handleAccept}>
{__("incoming_call_answer_button")}
</button>
</div>
</div>
);
@ -181,9 +198,10 @@ loop.conversation = (function(OT, mozL10n) {
// We'll probably really want to be getting this data from the
// background worker on the desktop client.
// Bug 1032700 should fix this.
this._conversation.setSessionData(sessionData[0]);
this._conversation.setIncomingSessionData(sessionData[0]);
this.loadReactComponent(loop.conversation.IncomingCallView({
model: this._conversation
model: this._conversation,
video: {enabled: this._conversation.hasVideoStream("incoming")}
}));
});
},
@ -213,7 +231,7 @@ loop.conversation = (function(OT, mozL10n) {
*/
declineAndBlock: function() {
navigator.mozLoop.stopAlerting();
var token = navigator.mozLoop.getLoopCharPref('loopToken');
var token = navigator.mozLoop.getLoopCharPref("loopToken");
this._client.deleteCallUrl(token, function(error) {
// 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
@ -235,10 +253,14 @@ loop.conversation = (function(OT, mozL10n) {
return;
}
var callType = this._conversation.get("selectedCallType");
var videoStream = callType === "audio" ? false : true;
/*jshint newcap:false*/
this.loadReactComponent(sharedViews.ConversationView({
sdk: OT,
model: this._conversation
model: this._conversation,
video: {enabled: videoStream}
}));
},

Просмотреть файл

@ -109,6 +109,11 @@ h1, h2, h3 {
height: auto;
}
.btn-large + .btn-chevron {
padding: 1rem;
height: 100%; /* match full height of button */
}
/*
* Left / Right padding elements
* used to center components
@ -133,17 +138,20 @@ h1, h2, h3 {
border: 1px solid #006b9d;
}
.btn-success {
.btn-success,
.btn-success + .btn-chevron {
background-color: #74bf43;
border: 1px solid #74bf43;
}
.btn-success:hover {
.btn-success:hover,
.btn-success + .btn-chevron:hover {
background-color: #6cb23e;
border: 1px solid #6cb23e;
}
.btn-success:active {
.btn-success:active,
.btn-success + .btn-chevron:active {
background-color: #64a43a;
border: 1px solid #64a43a;
}
@ -234,6 +242,8 @@ h1, h2, h3 {
.button-group .btn {
flex: 1;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
/* Alerts */
@ -292,24 +302,36 @@ h1, h2, h3 {
opacity: 0;
}
.btn-large .icon {
display: inline-block;
width: 20px;
height: 20px;
.icon,
.icon-small,
.icon-audio,
.icon-video {
background-size: 20px;
background-repeat: no-repeat;
vertical-align: top;
margin-left: 10px;
background-position: 80% center;
}
.icon-small {
background-size: 10px;
}
.icon-video {
background-image: url("../img/video-inverse-14x14.png");
}
.icon-audio {
background-image: url("../img/audio-default-16x16@1.5x.png");
}
@media (min-resolution: 2dppx) {
.icon-video {
background-image: url("../img/video-inverse-14x14@2x.png");
}
.icon-audio {
background-image: url("../img/audio-default-16x16@2x.png");
}
}
/*

Просмотреть файл

@ -194,6 +194,41 @@
font-weight: normal;
}
.call-audio-only {
width: 26px;
height: 26px;
border-left: 1px solid rgba(255,255,255,.4);
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
background-color: #74BF43;
background-image: url("../img/audio-inverse-14x14.png");
background-size: 1rem;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
}
.call-audio-only:hover {
background-color: #6cb23e;
}
.call-audio-video {
background-image: url("../img/video-inverse-14x14.png");
background-position: 96% center;
background-repeat: no-repeat;
background-size: 1rem;
}
@media (min-resolution: 2dppx) {
.call-audio-only {
background-image: url("../img/audio-inverse-14x14@2x.png");
}
.call-audio-video {
background-image: url("../img/video-inverse-14x14@2x.png");
}
}
/* Expired call url page */
.expired-url-info {
@ -212,9 +247,16 @@
font-weight: 300;
}
/* Block incoming call */
/*
* Dropdown menu hidden behind a chevron
*
* .native-dropdown-menu[-large-parent] Generic class, contains common styles
* .standalone-dropdown-menu Initiate call dropdown menu
* .conversation-window-dropdown Dropdown menu for answer/decline/block options
*/
.native-dropdown-menu {
.native-dropdown-menu,
.native-dropdown-large-parent {
/* Should match a native select menu */
padding: 0;
position: absolute; /* element can be wider than the parent */
@ -226,19 +268,35 @@
border-color: #aaa #111 #111 #aaa;
}
.decline-block-menu li {
padding: 0 10px 0 5px;
list-style: none;
font-size: .9em;
color: #000;
cursor: pointer;
}
.decline-block-menu li:hover {
color: #FFF;
background: #111;
/*
* If the component is smaller than the parent
* we need it to display block to occupy full width
* Same as above but overrides apropriate styles
*/
.native-dropdown-large-parent {
position: relative;
display: block;
}
.native-dropdown-menu li,
.native-dropdown-large-parent li {
list-style: none;
cursor: pointer;
color: #000;
}
.native-dropdown-menu li:hover,
.native-dropdown-large-parent li:hover,
.native-dropdown-large-parent li:hover button {
color: #fff;
background-color: #111;
}
.conversation-window-dropdown li {
padding: 0 10px 0 5px;
font-size: .9em;
}
/* Expired call url page */
.expired-url-info {

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 424 B

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 536 B

Просмотреть файл

@ -14,17 +14,21 @@ loop.shared.models = (function() {
*/
var ConversationModel = Backbone.Model.extend({
defaults: {
connected: false, // Session connected flag
ongoing: false, // Ongoing call flag
callerId: undefined, // Loop caller id
loopToken: undefined, // Loop conversation token
loopVersion: undefined, // Loop version for /calls/ information. This
// is the version received from the push
// notification and is used by the server to
// determine the pending calls
sessionId: undefined, // OT session id
sessionToken: undefined, // OT session token
apiKey: undefined // OT api key
connected: false, // Session connected flag
ongoing: false, // Ongoing call flag
callerId: undefined, // Loop caller id
loopToken: undefined, // Loop conversation token
loopVersion: undefined, // Loop version for /calls/ information. This
// is the version received from the push
// notification and is used by the server to
// determine the pending calls
sessionId: undefined, // OT session id
sessionToken: undefined, // OT session token
apiKey: undefined, // OT api key
callType: undefined, // The type of incoming call selected by
// other peer ("audio" or "audio-video")
selectedCallType: undefined // The selected type for the call that was
// initiated ("audio" or "audio-video")
},
/**
@ -114,7 +118,7 @@ loop.shared.models = (function() {
this._pendingCallTimer = setTimeout(
handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
this.setSessionData(sessionData);
this.setOutgoingSessionData(sessionData);
this.trigger("call:outgoing");
},
@ -129,10 +133,11 @@ loop.shared.models = (function() {
/**
* Sets session information.
* Session data received by creating an outgoing call.
*
* @param {Object} sessionData Conversation session information.
*/
setSessionData: function(sessionData) {
setOutgoingSessionData: function(sessionData) {
// Explicit property assignment to prevent later "surprises"
this.set({
sessionId: sessionData.sessionId,
@ -141,6 +146,21 @@ loop.shared.models = (function() {
});
},
/**
* Sets session information about the incoming call.
*
* @param {Object} sessionData Conversation session information.
*/
setIncomingSessionData: function(sessionData) {
// Explicit property assignment to prevent later "surprises"
this.set({
sessionId: sessionData.sessionId,
sessionToken: sessionData.sessionToken,
apiKey: sessionData.apiKey,
callType: sessionData.callType || "audio-video"
});
},
/**
* Starts a SDK session and subscribe to call events.
*/
@ -169,6 +189,22 @@ loop.shared.models = (function() {
.once("session:ended", this.stopListening, this);
},
/**
* Helper function to determine if video stream is available for the
* incoming or outgoing call
*
* @param {string} callType Incoming or outgoing call
*/
hasVideoStream: function(callType) {
if (callType === "incoming") {
return this.get("callType") === "audio-video";
}
if (callType === "outgoing") {
return this.get("selectedCallType") === "audio-video";
}
return undefined;
},
/**
* Handle a loop-server error, which has an optional `errno` property which
* is server error identifier.

Просмотреть файл

@ -217,13 +217,24 @@ loop.shared.views = (function(_, OT, l10n) {
}
},
getInitialProps: function() {
return {
video: {enabled: true},
audio: {enabled: true}
};
},
getInitialState: function() {
return {
video: {enabled: false},
audio: {enabled: false}
video: this.props.video,
audio: this.props.audio
};
},
componentWillMount: function() {
this.publisherConfig.publishVideo = this.props.video.enabled;
},
componentDidMount: function() {
this.listenTo(this.props.model, "session:connected",
this.startPublishing);

Просмотреть файл

@ -217,13 +217,24 @@ loop.shared.views = (function(_, OT, l10n) {
}
},
getInitialProps: function() {
return {
video: {enabled: true},
audio: {enabled: true}
};
},
getInitialState: function() {
return {
video: {enabled: false},
audio: {enabled: false}
video: this.props.video,
audio: this.props.audio
};
},
componentWillMount: function() {
this.publisherConfig.publishVideo = this.props.video.enabled;
},
componentDidMount: function() {
this.listenTo(this.props.model, "session:connected",
this.startPublishing);

Просмотреть файл

@ -124,3 +124,39 @@ header {
font-weight: normal;
}
.start-audio-only-call,
.start-audio-video-call {
background-color: none;
background-image: url("../shared/img/audio-default-16x16@1.5x.png");
background-position: 80% center;
background-size: 10px;
background-repeat: no-repeat;
cursor: pointer;
}
.start-audio-only-call {
border: none;
width: 100%;
}
.start-audio-only-call:hover {
background-image: url("../shared/img/audio-inverse-14x14.png");
}
.start-audio-video-call {
background-size: 20px;
background-image: url("../shared/img/video-inverse-14x14.png");
}
@media (min-resolution: 2dppx) {
.start-audio-only-call {
background-image: url("../shared/img/audio-default-16x16@2x.png");
}
.start-audio-only-call:hover {
background-image: url("../shared/img/audio-inverse-14x14@2x.png");
}
.start-audio-video-call {
background-image: url("../shared/img/video-inverse-14x14@2x.png");
}
}

Просмотреть файл

@ -144,7 +144,8 @@ loop.webapp = (function($, _, OT, webL10n) {
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false
disableCallButton: false,
showCallOptionsMenu: false
};
},
@ -157,6 +158,8 @@ loop.webapp = (function($, _, OT, webL10n) {
},
componentDidMount: function() {
// Listen for events & hide dropdown menu if user clicks away
window.addEventListener("click", this.clickHandler);
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
@ -173,10 +176,18 @@ loop.webapp = (function($, _, OT, webL10n) {
/**
* Initiates the call.
* Takes in a call type parameter "audio" or "audio-video" and returns
* a function that initiates the call. React click handler requires a function
* to be called when that event happenes.
*
* @param {string} User call type choice "audio" or "audio-video"
*/
_initiateOutgoingCall: function() {
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
_initiateOutgoingCall: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
}.bind(this);
},
_setConversationTimestamp: function(err, callUrlInfo) {
@ -191,6 +202,22 @@ loop.webapp = (function($, _, OT, webL10n) {
}
},
componentWillUnmount: function() {
window.removeEventListener("click", this.clickHandler);
},
clickHandler: function(e) {
if (!e.target.classList.contains('btn-chevron') &&
this.state.showCallOptionsMenu) {
this._toggleCallOptionsMenu();
}
},
_toggleCallOptionsMenu: function() {
var state = this.state.showCallOptionsMenu;
this.setState({showCallOptionsMenu: !state});
},
render: function() {
var tos_link_name = __("terms_of_use_link_text");
var privacy_notice_name = __("privacy_notice_link_text");
@ -202,8 +229,14 @@ loop.webapp = (function($, _, OT, webL10n) {
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
});
var callButtonClasses = "btn btn-success btn-large " +
var btnClassStartCall = "btn btn-large btn-success " +
"start-audio-video-call " +
loop.shared.utils.getTargetPlatform();
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
return (
/* jshint ignore:start */
@ -221,11 +254,37 @@ loop.webapp = (function($, _, OT, webL10n) {
React.DOM.div({className: "button-group"},
React.DOM.div({className: "flex-padding-1"}),
React.DOM.button({ref: "submitButton", onClick: this._initiateOutgoingCall,
className: callButtonClasses,
disabled: this.state.disableCallButton},
__("initiate_call_button"),
React.DOM.i({className: "icon icon-video"})
React.DOM.div({className: "button-chevron-menu-group"},
React.DOM.div({className: "button-group-chevron"},
React.DOM.div({className: "button-group"},
React.DOM.button({className: btnClassStartCall,
onClick: this._initiateOutgoingCall("audio-video"),
disabled: this.state.disableCallButton,
title: __("initiate_audio_video_call_tooltip")},
__("initiate_audio_video_call_button")
),
React.DOM.div({className: "btn-chevron",
onClick: this._toggleCallOptionsMenu}
)
),
React.DOM.ul({className: dropdownMenuClasses},
React.DOM.li(null,
/*
Button required for disabled state.
*/
React.DOM.button({className: "start-audio-only-call",
onClick: this._initiateOutgoingCall("audio"),
disabled: this.state.disableCallButton},
__("initiate_audio_call_button")
)
)
)
)
),
React.DOM.div({className: "flex-padding-1"})
),
@ -280,12 +339,12 @@ loop.webapp = (function($, _, OT, webL10n) {
this._notifier.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true});
} else {
var callType = this._conversation.get("selectedCallType");
this._conversation.once("call:outgoing", this.startCall, this);
// XXX For now, we assume both audio and video as there is no
// other option to select (bug 1048333)
this._client.requestCallInfo(this._conversation.get("loopToken"), "audio-video",
function(err, sessionData) {
this._client.requestCallInfo(this._conversation.get("loopToken"),
callType, function(err, sessionData) {
if (err) {
switch (err.errno) {
// loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is
@ -389,7 +448,8 @@ loop.webapp = (function($, _, OT, webL10n) {
}
this.loadReactComponent(sharedViews.ConversationView({
sdk: OT,
model: this._conversation
model: this._conversation,
video: {enabled: this._conversation.hasVideoStream("outgoing")}
}));
}
});

Просмотреть файл

@ -144,7 +144,8 @@ loop.webapp = (function($, _, OT, webL10n) {
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false
disableCallButton: false,
showCallOptionsMenu: false
};
},
@ -157,6 +158,8 @@ loop.webapp = (function($, _, OT, webL10n) {
},
componentDidMount: function() {
// Listen for events & hide dropdown menu if user clicks away
window.addEventListener("click", this.clickHandler);
this.props.model.listenTo(this.props.model, "session:error",
this._onSessionError);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
@ -173,10 +176,18 @@ loop.webapp = (function($, _, OT, webL10n) {
/**
* Initiates the call.
* Takes in a call type parameter "audio" or "audio-video" and returns
* a function that initiates the call. React click handler requires a function
* to be called when that event happenes.
*
* @param {string} User call type choice "audio" or "audio-video"
*/
_initiateOutgoingCall: function() {
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
_initiateOutgoingCall: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
}.bind(this);
},
_setConversationTimestamp: function(err, callUrlInfo) {
@ -191,6 +202,22 @@ loop.webapp = (function($, _, OT, webL10n) {
}
},
componentWillUnmount: function() {
window.removeEventListener("click", this.clickHandler);
},
clickHandler: function(e) {
if (!e.target.classList.contains('btn-chevron') &&
this.state.showCallOptionsMenu) {
this._toggleCallOptionsMenu();
}
},
_toggleCallOptionsMenu: function() {
var state = this.state.showCallOptionsMenu;
this.setState({showCallOptionsMenu: !state});
},
render: function() {
var tos_link_name = __("terms_of_use_link_text");
var privacy_notice_name = __("privacy_notice_link_text");
@ -202,8 +229,14 @@ loop.webapp = (function($, _, OT, webL10n) {
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
});
var callButtonClasses = "btn btn-success btn-large " +
var btnClassStartCall = "btn btn-large btn-success " +
"start-audio-video-call " +
loop.shared.utils.getTargetPlatform();
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
return (
/* jshint ignore:start */
@ -221,12 +254,38 @@ loop.webapp = (function($, _, OT, webL10n) {
<div className="button-group">
<div className="flex-padding-1"></div>
<button ref="submitButton" onClick={this._initiateOutgoingCall}
className={callButtonClasses}
disabled={this.state.disableCallButton}>
{__("initiate_call_button")}
<i className="icon icon-video"></i>
</button>
<div className="button-chevron-menu-group">
<div className="button-group-chevron">
<div className="button-group">
<button className={btnClassStartCall}
onClick={this._initiateOutgoingCall("audio-video")}
disabled={this.state.disableCallButton}
title={__("initiate_audio_video_call_tooltip")} >
{__("initiate_audio_video_call_button")}
</button>
<div className="btn-chevron"
onClick={this._toggleCallOptionsMenu}>
</div>
</div>
<ul className={dropdownMenuClasses}>
<li>
{/*
Button required for disabled state.
*/}
<button className="start-audio-only-call"
onClick={this._initiateOutgoingCall("audio")}
disabled={this.state.disableCallButton} >
{__("initiate_audio_call_button")}
</button>
</li>
</ul>
</div>
</div>
<div className="flex-padding-1"></div>
</div>
@ -280,12 +339,12 @@ loop.webapp = (function($, _, OT, webL10n) {
this._notifier.errorL10n("missing_conversation_info");
this.navigate("home", {trigger: true});
} else {
var callType = this._conversation.get("selectedCallType");
this._conversation.once("call:outgoing", this.startCall, this);
// XXX For now, we assume both audio and video as there is no
// other option to select (bug 1048333)
this._client.requestCallInfo(this._conversation.get("loopToken"), "audio-video",
function(err, sessionData) {
this._client.requestCallInfo(this._conversation.get("loopToken"),
callType, function(err, sessionData) {
if (err) {
switch (err.errno) {
// loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is
@ -389,7 +448,8 @@ loop.webapp = (function($, _, OT, webL10n) {
}
this.loadReactComponent(sharedViews.ConversationView({
sdk: OT,
model: this._conversation
model: this._conversation,
video: {enabled: this._conversation.hasVideoStream("outgoing")}
}));
}
});

Просмотреть файл

@ -25,7 +25,9 @@ promote_firefox_hello_heading=Download Firefox to make free audio and video call
get_firefox_button=Get Firefox
call_url_unavailable_notification=This URL is unavailable.
initiate_call_button_label=Click Call to start a video chat
initiate_call_button=Call
initiate_audio_video_call_button=Call
initiate_audio_video_call_tooltip=Start a video call
initiate_audio_call_button=Voice call
## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the
## part between {{..}}
legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}

Просмотреть файл

@ -119,7 +119,8 @@ describe("loop.conversation", function() {
pendingCallTimeout: 1000,
});
sandbox.stub(client, "requestCallsInfo");
sandbox.stub(conversation, "setSessionData");
sandbox.stub(conversation, "setIncomingSessionData");
sandbox.stub(conversation, "setOutgoingSessionData");
});
describe("Routes", function() {
@ -192,7 +193,8 @@ describe("loop.conversation", function() {
fakeSessionData = {
sessionId: "sessionId",
sessionToken: "sessionToken",
apiKey: "apiKey"
apiKey: "apiKey",
callType: "callType"
};
client.requestCallsInfo.callsArgWith(1, null, [fakeSessionData]);
@ -201,17 +203,31 @@ describe("loop.conversation", function() {
it("should store the session data", function() {
router.incoming(42);
sinon.assert.calledOnce(conversation.setSessionData);
sinon.assert.calledWithExactly(conversation.setSessionData,
sinon.assert.calledOnce(conversation.setIncomingSessionData);
sinon.assert.calledWithExactly(conversation.setIncomingSessionData,
fakeSessionData);
});
it("should call the view with video.enabled=false", function() {
sandbox.stub(conversation, "get").withArgs("callType").returns("audio");
router.incoming("fakeVersion");
sinon.assert.calledOnce(conversation.get);
sinon.assert.calledOnce(loop.conversation.IncomingCallView);
sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
{model: conversation,
video: {enabled: false}});
});
it("should display the incoming call view", function() {
sandbox.stub(conversation, "get").withArgs("callType")
.returns("audio-video");
router.incoming("fakeVersion");
sinon.assert.calledOnce(loop.conversation.IncomingCallView);
sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
{model: conversation});
{model: conversation,
video: {enabled: true}});
sinon.assert.calledOnce(router.loadReactComponent);
sinon.assert.calledWith(router.loadReactComponent,
sinon.match(function(value) {
@ -439,9 +455,32 @@ describe("loop.conversation", function() {
TestUtils.Simulate.click(buttonAccept);
sinon.assert.calledOnce(model.trigger);
/* Setting a model property triggers 2 events */
sinon.assert.calledThrice(model.trigger);
sinon.assert.calledWith(model.trigger, "accept");
});
sinon.assert.calledWith(model.trigger, "change:selectedCallType");
sinon.assert.calledWith(model.trigger, "change");
});
it("should set selectedCallType to audio-video", function() {
var buttonAccept = view.getDOMNode().querySelector(".call-audio-video");
sandbox.stub(model, "set");
TestUtils.Simulate.click(buttonAccept);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
});
it("should set selectedCallType to audio", function() {
var buttonAccept = view.getDOMNode().querySelector(".call-audio-only");
sandbox.stub(model, "set");
TestUtils.Simulate.click(buttonAccept);
sinon.assert.calledOnce(model.set);
sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
});
});
describe("click event on .btn-decline", function() {

Просмотреть файл

@ -24,7 +24,8 @@ describe("loop.shared.models", function() {
fakeSessionData = {
sessionId: "sessionId",
sessionToken: "sessionToken",
apiKey: "apiKey"
apiKey: "apiKey",
callType: "callType"
};
fakeSession = _.extend({
connect: function () {},
@ -101,13 +102,14 @@ describe("loop.shared.models", function() {
describe("#outgoing", function() {
beforeEach(function() {
sandbox.stub(conversation, "endSession");
sandbox.stub(conversation, "setSessionData");
sandbox.stub(conversation, "setOutgoingSessionData");
sandbox.stub(conversation, "setIncomingSessionData");
});
it("should save the sessionData", function() {
it("should save the outgoing sessionData", function() {
conversation.outgoing(fakeSessionData);
sinon.assert.calledOnce(conversation.setSessionData);
sinon.assert.calledOnce(conversation.setOutgoingSessionData);
});
it("should trigger a `call:outgoing` event", function(done) {
@ -139,13 +141,24 @@ describe("loop.shared.models", function() {
});
describe("#setSessionData", function() {
it("should update conversation session information", function() {
conversation.setSessionData(fakeSessionData);
it("should update outgoing conversation session information",
function() {
conversation.setOutgoingSessionData(fakeSessionData);
expect(conversation.get("sessionId")).eql("sessionId");
expect(conversation.get("sessionToken")).eql("sessionToken");
expect(conversation.get("apiKey")).eql("apiKey");
});
expect(conversation.get("sessionId")).eql("sessionId");
expect(conversation.get("sessionToken")).eql("sessionToken");
expect(conversation.get("apiKey")).eql("apiKey");
});
it("should update incoming conversation session information",
function() {
conversation.setIncomingSessionData(fakeSessionData);
expect(conversation.get("sessionId")).eql("sessionId");
expect(conversation.get("sessionToken")).eql("sessionToken");
expect(conversation.get("apiKey")).eql("apiKey");
expect(conversation.get("callType")).eql("callType");
});
});
describe("#startSession", function() {
@ -359,6 +372,30 @@ describe("loop.shared.models", function() {
sinon.assert.calledOnce(model.stopListening);
});
});
describe("#hasVideoStream", function() {
var model;
beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK,
pendingCallTimeout: 1000
});
model.startSession();
});
it("should return true for incoming callType", function() {
model.set("callType", "audio-video");
expect(model.hasVideoStream("incoming")).to.eql(true);
});
it("should return true for outgoing callType", function() {
model.set("selectedCallType", "audio-video");
expect(model.hasVideoStream("outgoing")).to.eql(true);
});
});
});
});
});

Просмотреть файл

@ -212,17 +212,37 @@ describe("loop.shared.views", function() {
it("should start a session", function() {
sandbox.stub(model, "startSession");
mountTestComponent({sdk: fakeSDK, model: model});
mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: true}
});
sinon.assert.calledOnce(model.startSession);
});
it("should set the correct stream publish options", function() {
var component = mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: false}
});
expect(component.publisherConfig.publishVideo).to.eql(false);
});
});
describe("constructed", function() {
var comp;
beforeEach(function() {
comp = mountTestComponent({sdk: fakeSDK, model: model});
comp = mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: false}
});
});
describe("#hangup", function() {
@ -293,7 +313,11 @@ describe("loop.shared.views", function() {
var comp;
beforeEach(function() {
comp = mountTestComponent({sdk: fakeSDK, model: model});
comp = mountTestComponent({
sdk: fakeSDK,
model: model,
video: {enabled: false}
});
comp.startPublishing();
});

Просмотреть файл

@ -302,6 +302,7 @@ describe("loop.webapp", function() {
describe("Has loop token", function() {
beforeEach(function() {
conversation.set("loopToken", "fakeToken");
conversation.set("selectedCallType", "audio-video");
sandbox.stub(conversation, "outgoing");
});
@ -400,21 +401,59 @@ describe("loop.webapp", function() {
});
it("should start the conversation establishment process", function() {
var button = view.getDOMNode().querySelector("button");
var button = view.getDOMNode().querySelector(".start-audio-video-call");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall);
});
it("should disable current form once session is initiated", function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector("button");
it("should start the conversation establishment process", function() {
var button = view.getDOMNode().querySelector(".start-audio-only-call");
React.addons.TestUtils.Simulate.click(button);
expect(button.disabled).to.eql(true);
sinon.assert.calledOnce(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall);
});
it("should disable audio-video button once session is initiated",
function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector(".start-audio-video-call");
React.addons.TestUtils.Simulate.click(button);
expect(button.disabled).to.eql(true);
});
it("should disable audio-only button once session is initiated",
function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector(".start-audio-only-call");
React.addons.TestUtils.Simulate.click(button);
expect(button.disabled).to.eql(true);
});
it("should set selectedCallType to audio", function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector(".start-audio-only-call");
React.addons.TestUtils.Simulate.click(button);
expect(conversation.get("selectedCallType")).to.eql("audio");
});
it("should set selectedCallType to audio-video", function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector(".start-audio-video-call");
React.addons.TestUtils.Simulate.click(button);
expect(conversation.get("selectedCallType")).to.eql("audio-video");
});
it("should set state.urlCreationDateString to a locale date string",
function() {
// wrap in a jquery object because text is broken up

Просмотреть файл

@ -17,6 +17,7 @@ unable_retrieve_url=Sorry, we were unable to retrieve a call url.
incoming_call_title=Incoming Call…
incoming_call=Incoming call
incoming_call_answer_button=Answer
incoming_call_answer_audio_only_tooltip=Answer with voice
incoming_call_decline_button=Decline
incoming_call_decline_and_block_button=Decline and Block
incoming_call_block_button=Block