Bug 1000240 - Added a Call Failed view for Loop standalone. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-10-01 15:16:05 +01:00
Родитель 683cde0081
Коммит e1f1020688
14 изменённых файлов: 421 добавлений и 350 удалений

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

@ -68,15 +68,6 @@ loop.conversation = (function(mozL10n) {
return false;
},
_toggleDeclineMenu: function() {
var currentState = this.state.showDeclineMenu;
this.setState({showDeclineMenu: !currentState});
},
_hideDeclineMenu: function() {
this.setState({showDeclineMenu: false});
},
/*
* Generate props for <AcceptCallButton> component based on
* incoming call type. An incoming video call will render a video

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

@ -12,10 +12,12 @@ loop.conversation = (function(mozL10n) {
"use strict";
var sharedViews = loop.shared.views;
var sharedMixins = loop.shared.mixins;
var sharedModels = loop.shared.models;
var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
var IncomingCallView = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
model: React.PropTypes.object.isRequired,
@ -24,25 +26,11 @@ loop.conversation = (function(mozL10n) {
getDefaultProps: function() {
return {
showDeclineMenu: false,
showMenu: false,
video: true
};
},
getInitialState: function() {
return {showDeclineMenu: this.props.showDeclineMenu};
},
componentDidMount: function() {
window.addEventListener("click", this.clickHandler);
window.addEventListener("blur", this._hideDeclineMenu);
},
componentWillUnmount: function() {
window.removeEventListener("click", this.clickHandler);
window.removeEventListener("blur", this._hideDeclineMenu);
},
clickHandler: function(e) {
var target = e.target;
if (!target.classList.contains('btn-chevron')) {
@ -68,15 +56,6 @@ loop.conversation = (function(mozL10n) {
return false;
},
_toggleDeclineMenu: function() {
var currentState = this.state.showDeclineMenu;
this.setState({showDeclineMenu: !currentState});
},
_hideDeclineMenu: function() {
this.setState({showDeclineMenu: false});
},
/*
* Generate props for <AcceptCallButton> component based on
* incoming call type. An incoming video call will render a video
@ -113,7 +92,7 @@ loop.conversation = (function(mozL10n) {
var dropdownMenuClassesDecline = React.addons.classSet({
"native-dropdown-menu": true,
"conversation-window-dropdown": true,
"visually-hidden": !this.state.showDeclineMenu
"visually-hidden": !this.state.showMenu
});
return (
<div className="call-window">
@ -126,13 +105,11 @@ loop.conversation = (function(mozL10n) {
<div className="btn-group-chevron">
<div className="btn-group">
<button className="btn btn-error btn-decline"
<button className="btn btn-decline"
onClick={this._handleDecline}>
{mozL10n.get("incoming_call_cancel_button")}
</button>
<div className="btn-chevron"
onClick={this._toggleDeclineMenu}>
</div>
<div className="btn-chevron" onClick={this.toggleDropdownMenu} />
</div>
<ul className={dropdownMenuClassesDecline}>

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

@ -137,7 +137,9 @@ p {
.btn-cancel,
.btn-error,
.btn-decline,
.btn-hangup,
.btn-decline + .btn-chevron,
.btn-error + .btn-chevron {
background-color: #d74345;
border: 1px solid #d74345;
@ -145,7 +147,9 @@ p {
.btn-cancel:hover,
.btn-error:hover,
.btn-decline:hover,
.btn-hangup:hover,
.btn-decline + .btn-chevron:hover,
.btn-error + .btn-chevron:hover {
background-color: #c53436;
border: 1px solid #c53436;
@ -153,7 +157,9 @@ p {
.btn-cancel:active,
.btn-error:active,
.btn-decline:active,
.btn-hangup:active,
.btn-decline + .btn-chevron:active,
.btn-error + .btn-chevron:active {
background-color: #ae2325;
border: 1px solid #ae2325;
@ -182,6 +188,7 @@ p {
}
.btn-group-chevron .btn {
border-radius: 2px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
flex: 2;
@ -369,7 +376,7 @@ p {
padding: 20px 0;
border: 1px solid #e7e7e7;
box-shadow: 0 2px 0 rgba(0, 0, 0, .03);
margin-bottom: 25px;
margin: 2rem 0;
}
.info-panel h1 {

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

@ -31,6 +31,10 @@ loop.shared.mixins = (function() {
* @type {Object}
*/
var DropdownMenuMixin = {
get documentBody() {
return rootObject.document.body;
},
getInitialState: function() {
return {showMenu: false};
},
@ -40,11 +44,13 @@ loop.shared.mixins = (function() {
},
componentDidMount: function() {
rootObject.document.body.addEventListener("click", this._onBodyClick);
this.documentBody.addEventListener("click", this._onBodyClick);
this.documentBody.addEventListener("blur", this.hideDropdownMenu);
},
componentWillUnmount: function() {
rootObject.document.body.removeEventListener("click", this._onBodyClick);
this.documentBody.removeEventListener("click", this._onBodyClick);
this.documentBody.removeEventListener("blur", this.hideDropdownMenu);
},
showDropdownMenu: function() {
@ -53,7 +59,11 @@ loop.shared.mixins = (function() {
hideDropdownMenu: function() {
this.setState({showMenu: false});
}
},
toggleDropdownMenu: function() {
this.setState({showMenu: !this.state.showMenu});
},
};
/**

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

@ -29,8 +29,8 @@ loop.shared.models = (function(l10n) {
// requires.
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")
selectedCallType: "audio-video", // The selected type for the call that was
// initiated ("audio" or "audio-video")
callToken: undefined, // Incoming call token.
// Used for blocking a call url
subscribedStream: false, // Used to indicate that a stream has been
@ -86,8 +86,13 @@ loop.shared.models = (function(l10n) {
/**
* Used to indicate that an outgoing call should start any necessary
* set-up.
*
* @param {String} selectedCallType Call type ("audio" or "audio-video")
*/
setupOutgoingCall: function() {
setupOutgoingCall: function(selectedCallType) {
if (selectedCallType) {
this.set("selectedCallType", selectedCallType);
}
this.trigger("call:outgoing:setup");
},

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

@ -115,8 +115,9 @@ body,
line-height: 2.2rem;
}
.standalone-btn-label {
p.standalone-btn-label {
font-size: 1.2rem;
line-height: 1.5rem;
}
.light-color-font {

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

@ -14,9 +14,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views,
sharedUtils = loop.shared.utils;
var sharedMixins = loop.shared.mixins;
var sharedModels = loop.shared.models;
var sharedViews = loop.shared.views;
var sharedUtils = loop.shared.utils;
/**
* Homepage view.
@ -116,7 +117,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
render: function() {
return (
React.DOM.h1({className: "standalone-header-title"},
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
React.DOM.strong(null, mozL10n.get("brandShortname")),
mozL10n.get("clientShortname")
)
);
}
@ -305,53 +307,105 @@ loop.webapp = (function($, _, OT, mozL10n) {
React.DOM.div({className: "flex-padding-1"})
)
),
ConversationFooter(null)
)
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
*
* Required properties:
* - {loop.shared.models.ConversationModel} model Conversation model.
* - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({displayName: 'StartConversationView',
var InitiateCallButton = React.createClass({displayName: 'InitiateCallButton',
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
model: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(sharedModels.ConversationModel),
React.PropTypes.instanceOf(FxOSConversationModel)
]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
caption: React.PropTypes.string.isRequired,
startCall: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool
},
getDefaultProps: function() {
return {showCallOptionsMenu: false};
return {disabled: false};
},
render: function() {
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showMenu
});
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.props.disabled
});
return (
React.DOM.div({className: "standalone-btn-chevron-menu-group"},
React.DOM.div({className: "btn-group-chevron"},
React.DOM.div({className: "btn-group"},
React.DOM.button({className: "btn btn-large btn-accept",
onClick: this.props.startCall("audio-video"),
disabled: this.props.disabled,
title: mozL10n.get("initiate_audio_video_call_tooltip2")},
React.DOM.span({className: "standalone-call-btn-text"},
this.props.caption
),
React.DOM.span({className: "standalone-call-btn-video-icon"})
),
React.DOM.div({className: chevronClasses,
onClick: this.toggleDropdownMenu}
)
),
React.DOM.ul({className: dropdownMenuClasses},
React.DOM.li(null,
React.DOM.button({className: "start-audio-only-call",
onClick: this.props.startCall("audio"),
disabled: this.props.disabled},
mozL10n.get("initiate_audio_call_button2")
)
)
)
)
)
);
}
});
/**
* Initiate conversation view.
*/
var InitiateConversationView = React.createClass({displayName: 'InitiateConversationView',
mixins: [Backbone.Events],
propTypes: {
conversation: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(sharedModels.ConversationModel),
React.PropTypes.instanceOf(FxOSConversationModel)
]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired,
title: React.PropTypes.string.isRequired,
callButtonLabel: React.PropTypes.string.isRequired
},
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false,
showCallOptionsMenu: this.props.showCallOptionsMenu
disableCallButton: false
};
},
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.model.listenTo(this.props.model, "fxos:app-needed",
this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
this.listenTo(this.props.conversation,
"session:error", this._onSessionError);
this.listenTo(this.props.conversation,
"fxos:app-needed", this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(
this.props.conversation.get("loopToken"),
this._setConversationTimestamp);
},
componentWillUnmount: function() {
this.stopListening(this.props.conversation);
localStorage.setItem("has-seen-tos", "true");
},
_onSessionError: function(error, l10nProps) {
@ -362,11 +416,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
_onFxOSAppNeeded: function() {
this.setState({
marketplaceSrc: loop.config.marketplaceUrl
});
this.setState({
onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
this.props.model
marketplaceSrc: loop.config.marketplaceUrl,
onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
this.props.conversation
)
});
},
@ -379,11 +431,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
*
* @param {string} User call type choice "audio" or "audio-video"
*/
_initiateOutgoingCall: function(callType) {
startCall: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
}.bind(this);
},
@ -398,47 +449,21 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
},
componentWillUnmount: function() {
window.removeEventListener("click", this.clickHandler);
localStorage.setItem("has-seen-tos", "true");
},
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 = mozL10n.get("terms_of_use_link_text");
var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
var tosLinkName = mozL10n.get("terms_of_use_link_text");
var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
var tosHTML = mozL10n.get("legal_text_and_links", {
"terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
tos_link_name + "</a>",
tosLinkName + "</a>",
"privacy_notice_url": "<a target=_blank href='" +
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
"https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
});
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.state.disableCallButton
});
return (
React.DOM.div({className: "container"},
@ -448,47 +473,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
urlCreationDateString: this.state.urlCreationDateString}),
React.DOM.p({className: "standalone-btn-label"},
mozL10n.get("initiate_call_button_label2")
this.props.title
),
React.DOM.div({id: "messages"}),
React.DOM.div({className: "btn-group"},
React.DOM.div({className: "flex-padding-1"}),
React.DOM.div({className: "standalone-btn-chevron-menu-group"},
React.DOM.div({className: "btn-group-chevron"},
React.DOM.div({className: "btn-group"},
React.DOM.button({className: "btn btn-large btn-accept",
onClick: this._initiateOutgoingCall("audio-video"),
disabled: this.state.disableCallButton,
title: mozL10n.get("initiate_audio_video_call_tooltip2")},
React.DOM.span({className: "standalone-call-btn-text"},
mozL10n.get("initiate_audio_video_call_button2")
),
React.DOM.span({className: "standalone-call-btn-video-icon"})
),
React.DOM.div({className: chevronClasses,
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},
mozL10n.get("initiate_audio_call_button2")
)
)
)
)
InitiateCallButton({
caption: this.props.callButtonLabel,
disabled: this.state.disableCallButton,
startCall: this.startCall}
),
React.DOM.div({className: "flex-padding-1"})
),
@ -538,6 +533,26 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var StartConversationView = React.createClass({displayName: 'StartConversationView',
render: function() {
return this.transferPropsTo(
InitiateConversationView({
title: mozL10n.get("initiate_call_button_label2"),
callButtonLabel: mozL10n.get("initiate_audio_video_call_button2")})
);
}
});
var FailedConversationView = React.createClass({displayName: 'FailedConversationView',
render: function() {
return this.transferPropsTo(
InitiateConversationView({
title: mozL10n.get("call_failed_title"),
callButtonLabel: mozL10n.get("retry_call_button")})
);
}
});
/**
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
@ -595,11 +610,19 @@ loop.webapp = (function($, _, OT, mozL10n) {
*/
render: function() {
switch (this.state.callStatus) {
case "failure":
case "start": {
return (
StartConversationView({
model: this.props.conversation,
conversation: this.props.conversation,
notifications: this.props.notifications,
client: this.props.client}
)
);
}
case "failure": {
return (
FailedConversationView({
conversation: this.props.conversation,
notifications: this.props.notifications,
client: this.props.client}
)
@ -775,18 +798,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
* @param {String} reason The reason the call was terminated (reject, busy,
* timeout, cancel, media-fail, user-unknown, closed)
*/
_handleCallTerminated: function(reason) {
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this.props.notifications.errorL10n("call_timeout_notification_text");
if (reason === "cancel") {
this.setState({callStatus: "start"});
return;
}
// redirects the user to the call start view
// XXX should switch callStatus to failed for specific reasons when we
// get the call failed view; for now, switch back to start.
this.setState({callStatus: "start"});
// XXX later, we'll want to display more meaningfull messages (needs UX)
this.props.notifications.errorL10n("call_timeout_notification_text");
this.setState({callStatus: "failure"});
},
/**
@ -893,6 +915,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
FailedConversationView: FailedConversationView,
OutgoingConversationView: OutgoingConversationView,
EndedConversationView: EndedConversationView,
HomeView: HomeView,

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

@ -14,9 +14,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views,
sharedUtils = loop.shared.utils;
var sharedMixins = loop.shared.mixins;
var sharedModels = loop.shared.models;
var sharedViews = loop.shared.views;
var sharedUtils = loop.shared.utils;
/**
* Homepage view.
@ -116,7 +117,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
render: function() {
return (
<h1 className="standalone-header-title">
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
<strong>{mozL10n.get("brandShortname")}</strong>
{mozL10n.get("clientShortname")}
</h1>
);
}
@ -234,7 +236,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
<h3 className="call-url">
{conversationUrl}
</h3>
<h4 className={urlCreationDateClasses} >
<h4 className={urlCreationDateClasses}>
{callUrlCreationDateString}
</h4>
</header>
@ -286,72 +288,124 @@ loop.webapp = (function($, _, OT, mozL10n) {
<ConversationBranding />
</header>
<div id="cameraPreview"></div>
<div id="cameraPreview" />
<div id="messages"></div>
<div id="messages" />
<p className="standalone-btn-label">
{callState}
</p>
<div className="btn-pending-cancel-group btn-group">
<div className="flex-padding-1"></div>
<div className="flex-padding-1" />
<button className="btn btn-large btn-cancel"
onClick={this._cancelOutgoingCall} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_call_cancel_button")}
</span>
</button>
<div className="flex-padding-1"></div>
<div className="flex-padding-1" />
</div>
</div>
<ConversationFooter />
</div>
);
}
});
/**
* Conversation launcher view. A ConversationModel is associated and attached
* as a `model` property.
*
* Required properties:
* - {loop.shared.models.ConversationModel} model Conversation model.
* - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({
var InitiateCallButton = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin],
propTypes: {
model: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(sharedModels.ConversationModel),
React.PropTypes.instanceOf(FxOSConversationModel)
]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
caption: React.PropTypes.string.isRequired,
startCall: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool
},
getDefaultProps: function() {
return {showCallOptionsMenu: false};
return {disabled: false};
},
render: function() {
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showMenu
});
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.props.disabled
});
return (
<div className="standalone-btn-chevron-menu-group">
<div className="btn-group-chevron">
<div className="btn-group">
<button className="btn btn-large btn-accept"
onClick={this.props.startCall("audio-video")}
disabled={this.props.disabled}
title={mozL10n.get("initiate_audio_video_call_tooltip2")}>
<span className="standalone-call-btn-text">
{this.props.caption}
</span>
<span className="standalone-call-btn-video-icon" />
</button>
<div className={chevronClasses}
onClick={this.toggleDropdownMenu}>
</div>
</div>
<ul className={dropdownMenuClasses}>
<li>
<button className="start-audio-only-call"
onClick={this.props.startCall("audio")}
disabled={this.props.disabled}>
{mozL10n.get("initiate_audio_call_button2")}
</button>
</li>
</ul>
</div>
</div>
);
}
});
/**
* Initiate conversation view.
*/
var InitiateConversationView = React.createClass({
mixins: [Backbone.Events],
propTypes: {
conversation: React.PropTypes.oneOfType([
React.PropTypes.instanceOf(sharedModels.ConversationModel),
React.PropTypes.instanceOf(FxOSConversationModel)
]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired,
title: React.PropTypes.string.isRequired,
callButtonLabel: React.PropTypes.string.isRequired
},
getInitialState: function() {
return {
urlCreationDateString: '',
disableCallButton: false,
showCallOptionsMenu: this.props.showCallOptionsMenu
disableCallButton: false
};
},
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.model.listenTo(this.props.model, "fxos:app-needed",
this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
this.listenTo(this.props.conversation,
"session:error", this._onSessionError);
this.listenTo(this.props.conversation,
"fxos:app-needed", this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(
this.props.conversation.get("loopToken"),
this._setConversationTimestamp);
},
componentWillUnmount: function() {
this.stopListening(this.props.conversation);
localStorage.setItem("has-seen-tos", "true");
},
_onSessionError: function(error, l10nProps) {
@ -362,11 +416,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
_onFxOSAppNeeded: function() {
this.setState({
marketplaceSrc: loop.config.marketplaceUrl
});
this.setState({
onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
this.props.model
marketplaceSrc: loop.config.marketplaceUrl,
onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
this.props.conversation
)
});
},
@ -379,11 +431,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
*
* @param {string} User call type choice "audio" or "audio-video"
*/
_initiateOutgoingCall: function(callType) {
startCall: function(callType) {
return function() {
this.props.model.set("selectedCallType", callType);
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
this.props.model.setupOutgoingCall();
}.bind(this);
},
@ -398,47 +449,21 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
},
componentWillUnmount: function() {
window.removeEventListener("click", this.clickHandler);
localStorage.setItem("has-seen-tos", "true");
},
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 = mozL10n.get("terms_of_use_link_text");
var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
var tosLinkName = mozL10n.get("terms_of_use_link_text");
var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
var tosHTML = mozL10n.get("legal_text_and_links", {
"terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
tos_link_name + "</a>",
tosLinkName + "</a>",
"privacy_notice_url": "<a target=_blank href='" +
"https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
"https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
});
var dropdownMenuClasses = React.addons.classSet({
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
var chevronClasses = React.addons.classSet({
"btn-chevron": true,
"disabled": this.state.disableCallButton
});
return (
<div className="container">
@ -448,49 +473,19 @@ loop.webapp = (function($, _, OT, mozL10n) {
urlCreationDateString={this.state.urlCreationDateString} />
<p className="standalone-btn-label">
{mozL10n.get("initiate_call_button_label2")}
{this.props.title}
</p>
<div id="messages"></div>
<div className="btn-group">
<div className="flex-padding-1"></div>
<div className="standalone-btn-chevron-menu-group">
<div className="btn-group-chevron">
<div className="btn-group">
<button className="btn btn-large btn-accept"
onClick={this._initiateOutgoingCall("audio-video")}
disabled={this.state.disableCallButton}
title={mozL10n.get("initiate_audio_video_call_tooltip2")} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_audio_video_call_button2")}
</span>
<span className="standalone-call-btn-video-icon"></span>
</button>
<div className={chevronClasses}
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} >
{mozL10n.get("initiate_audio_call_button2")}
</button>
</li>
</ul>
</div>
</div>
<div className="flex-padding-1"></div>
<div className="flex-padding-1" />
<InitiateCallButton
caption={this.props.callButtonLabel}
disabled={this.state.disableCallButton}
startCall={this.startCall}
/>
<div className="flex-padding-1" />
</div>
<p className={tosClasses}
@ -538,6 +533,26 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
var StartConversationView = React.createClass({
render: function() {
return this.transferPropsTo(
<InitiateConversationView
title={mozL10n.get("initiate_call_button_label2")}
callButtonLabel={mozL10n.get("initiate_audio_video_call_button2")} />
);
}
});
var FailedConversationView = React.createClass({
render: function() {
return this.transferPropsTo(
<InitiateConversationView
title={mozL10n.get("call_failed_title")}
callButtonLabel={mozL10n.get("retry_call_button")} />
);
}
});
/**
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
@ -595,11 +610,19 @@ loop.webapp = (function($, _, OT, mozL10n) {
*/
render: function() {
switch (this.state.callStatus) {
case "failure":
case "start": {
return (
<StartConversationView
model={this.props.conversation}
conversation={this.props.conversation}
notifications={this.props.notifications}
client={this.props.client}
/>
);
}
case "failure": {
return (
<FailedConversationView
conversation={this.props.conversation}
notifications={this.props.notifications}
client={this.props.client}
/>
@ -775,18 +798,17 @@ loop.webapp = (function($, _, OT, mozL10n) {
/**
* Handles call rejection.
*
* @param {String} reason The reason the call was terminated.
* @param {String} reason The reason the call was terminated (reject, busy,
* timeout, cancel, media-fail, user-unknown, closed)
*/
_handleCallTerminated: function(reason) {
if (reason !== "cancel") {
// XXX This should really display the call failed view - bug 1046959
// will implement this.
this.props.notifications.errorL10n("call_timeout_notification_text");
if (reason === "cancel") {
this.setState({callStatus: "start"});
return;
}
// redirects the user to the call start view
// XXX should switch callStatus to failed for specific reasons when we
// get the call failed view; for now, switch back to start.
this.setState({callStatus: "start"});
// XXX later, we'll want to display more meaningfull messages (needs UX)
this.props.notifications.errorL10n("call_timeout_notification_text");
this.setState({callStatus: "failure"});
},
/**
@ -893,6 +915,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
CallUrlExpiredView: CallUrlExpiredView,
PendingConversationView: PendingConversationView,
StartConversationView: StartConversationView,
FailedConversationView: FailedConversationView,
OutgoingConversationView: OutgoingConversationView,
EndedConversationView: EndedConversationView,
HomeView: HomeView,

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

@ -5,6 +5,7 @@ call_timeout_notification_text=Your call did not go through.
missing_conversation_info=Missing conversation information.
network_disconnected=The network connection terminated abruptly.
peer_ended_conversation2=The person you were calling has ended the conversation.
call_failed_title=Call failed.
connection_error_see_console_notification=Call failed; see console for details.
generic_failure_title=Something went wrong.
generic_failure_with_reason2=You can try again or email a link to be reached at later.

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

@ -76,6 +76,19 @@ describe("loop.shared.models", function() {
});
describe("#setupOutgoingCall", function() {
it("should set the a custom selected call type", function() {
conversation.setupOutgoingCall("audio");
expect(conversation.get("selectedCallType")).eql("audio");
});
it("should respect the default selected call type when none is passed",
function() {
conversation.setupOutgoingCall();
expect(conversation.get("selectedCallType")).eql("audio-video");
});
it("should trigger a `call:outgoing:setup` event", function(done) {
conversation.once("call:outgoing:setup", function() {
done();

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

@ -196,14 +196,14 @@ describe("loop.webapp", function() {
sandbox.stub(notifications, "errorL10n");
});
it("should display the StartConversationView", function() {
it("should display the FailedConversationView", function() {
ocView._websocket.trigger("progress", {
state: "terminated",
reason: "reject"
});
TestUtils.findRenderedComponentWithType(ocView,
loop.webapp.StartConversationView);
loop.webapp.FailedConversationView);
});
it("should display an error message if the reason is not 'cancel'",
@ -271,14 +271,14 @@ describe("loop.webapp", function() {
});
describe("call:outgoing", function() {
it("should set display the StartConversationView if session token is missing",
it("should display FailedConversationView if session token is missing",
function() {
conversation.set("loopToken", "");
ocView.startCall();
TestUtils.findRenderedComponentWithType(ocView,
loop.webapp.StartConversationView);
loop.webapp.FailedConversationView);
});
it("should notify the user if session token is missing", function() {
@ -400,11 +400,11 @@ describe("loop.webapp", function() {
conversation.set("loopToken", "");
});
it("should set display the StartConversationView", function() {
it("should display the FailedConversationView", function() {
conversation.setupOutgoingCall();
TestUtils.findRenderedComponentWithType(ocView,
loop.webapp.StartConversationView);
loop.webapp.FailedConversationView);
});
it("should display an error", function() {
@ -416,13 +416,12 @@ describe("loop.webapp", function() {
describe("Has loop token", function() {
beforeEach(function() {
conversation.set("selectedCallType", "audio-video");
sandbox.stub(conversation, "outgoing");
});
it("should call requestCallInfo on the client",
function() {
conversation.setupOutgoingCall();
conversation.setupOutgoingCall("audio-video");
sinon.assert.calledOnce(client.requestCallInfo);
sinon.assert.calledWith(client.requestCallInfo, "fakeToken",
@ -440,14 +439,14 @@ describe("loop.webapp", function() {
loop.webapp.CallUrlExpiredView);
});
it("should set display the StartConversationView on any other error",
it("should set display the FailedConversationView on any other error",
function() {
client.requestCallInfo.callsArgWith(2, {errno: 104});
conversation.setupOutgoingCall();
TestUtils.findRenderedComponentWithType(ocView,
loop.webapp.StartConversationView);
loop.webapp.FailedConversationView);
});
it("should notify the user on any other error", function() {
@ -585,8 +584,7 @@ describe("loop.webapp", function() {
describe("StartConversationView", function() {
describe("#initiate", function() {
var conversation, setupOutgoingCall, view, fakeSubmitEvent,
requestCallUrlInfo;
var conversation, view, fakeSubmitEvent, requestCallUrlInfo;
beforeEach(function() {
conversation = new sharedModels.ConversationModel({}, {
@ -594,7 +592,6 @@ describe("loop.webapp", function() {
});
fakeSubmitEvent = {preventDefault: sinon.spy()};
setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
var standaloneClientStub = {
requestCallUrlInfo: function(token, cb) {
@ -605,7 +602,7 @@ describe("loop.webapp", function() {
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
conversation: conversation,
notifications: notifications,
client: standaloneClientStub
})
@ -614,20 +611,24 @@ describe("loop.webapp", function() {
it("should start the audio-video conversation establishment process",
function() {
var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
var button = view.getDOMNode().querySelector(".btn-accept");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall, "audio-video");
});
it("should start the audio-only conversation establishment process",
function() {
var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
var button = view.getDOMNode().querySelector(".start-audio-only-call");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall);
sinon.assert.calledWithExactly(setupOutgoingCall, "audio");
});
it("should disable audio-video button once session is initiated",
@ -650,35 +651,35 @@ describe("loop.webapp", function() {
expect(button.disabled).to.eql(true);
});
it("should set selectedCallType to audio", function() {
conversation.set("loopToken", "fake");
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);
var button = view.getDOMNode().querySelector(".start-audio-only-call");
React.addons.TestUtils.Simulate.click(button);
expect(conversation.get("selectedCallType")).to.eql("audio");
});
expect(conversation.get("selectedCallType")).to.eql("audio");
});
it("should set selectedCallType to audio-video", function() {
conversation.set("loopToken", "fake");
it("should set selectedCallType to audio-video", function() {
conversation.set("loopToken", "fake");
var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
React.addons.TestUtils.Simulate.click(button);
var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
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
// into several span elements
var date = new Date(0);
var options = {year: "numeric", month: "long", day: "numeric"};
var timestamp = date.toLocaleDateString(navigator.language, options);
expect(view.state.urlCreationDateString).to.eql(timestamp);
expect(conversation.get("selectedCallType")).to.eql("audio-video");
});
// XXX this test breaks while the feature actually works; find a way to
// test this properly.
it.skip("should set state.urlCreationDateString to a locale date string",
function() {
var date = new Date();
var options = {year: "numeric", month: "long", day: "numeric"};
var timestamp = date.toLocaleDateString(navigator.language, options);
var dateElem = view.getDOMNode().querySelector(".call-url-date");
expect(dateElem.textContent).to.eql(timestamp);
});
});
describe("Events", function() {
@ -697,7 +698,7 @@ describe("loop.webapp", function() {
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
conversation: conversation,
notifications: notifications,
client: {requestCallUrlInfo: requestCallUrlInfo}
})
@ -782,7 +783,7 @@ describe("loop.webapp", function() {
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
conversation: conversation,
notifications: notifications,
client: {requestCallUrlInfo: requestCallUrlInfo}
})
@ -798,7 +799,7 @@ describe("loop.webapp", function() {
localStorage.setItem("has-seen-tos", "true");
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
conversation: conversation,
notifications: notifications,
client: {requestCallUrlInfo: requestCallUrlInfo}
})
@ -888,7 +889,7 @@ describe("loop.webapp", function() {
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
conversation: conversation,
notifications: notifications,
client: standaloneClientStub
})
@ -1003,7 +1004,7 @@ describe("loop.webapp", function() {
before(function() {
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: model,
conversation: model,
notifications: notifications,
client: {requestCallUrlInfo: sandbox.stub()}
})

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

@ -32,12 +32,13 @@
<script src="../content/shared/libs/lodash-2.4.1.js"></script>
<script src="../content/shared/libs/backbone-1.1.2.js"></script>
<script src="../content/shared/js/feedbackApiClient.js"></script>
<script src="../content/shared/js/conversationStore.js"></script>
<script src="../content/shared/js/actions.js"></script>
<script src="../content/shared/js/utils.js"></script>
<script src="../content/shared/js/models.js"></script>
<script src="../content/shared/js/mixins.js"></script>
<script src="../content/shared/js/views.js"></script>
<script src="../content/shared/js/websocket.js"></script>
<script src="../content/shared/js/conversationStore.js"></script>
<script src="../content/js/conversationViews.js"></script>
<script src="../content/js/client.js"></script>
<script src="../standalone/content/js/webapp.js"></script>

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

@ -19,12 +19,13 @@
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
@ -175,8 +176,7 @@
Example({summary: "Default", dashed: "true", style: {width: "260px", height: "254px"}},
React.DOM.div({className: "fx-embedded"},
IncomingCallView({model: mockConversationModel,
showDeclineMenu: true,
video: true})
showMenu: true})
)
)
),
@ -252,10 +252,19 @@
Section({name: "StartConversationView"},
Example({summary: "Start conversation view", dashed: "true"},
React.DOM.div({className: "standalone"},
StartConversationView({model: mockConversationModel,
StartConversationView({conversation: mockConversationModel,
client: mockClient,
notifications: notifications,
showCallOptionsMenu: true})
notifications: notifications})
)
)
),
Section({name: "FailedConversationView"},
Example({summary: "Failed conversation view", dashed: "true"},
React.DOM.div({className: "standalone"},
FailedConversationView({conversation: mockConversationModel,
client: mockClient,
notifications: notifications})
)
)
),

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

@ -19,12 +19,13 @@
// 2. Standalone webapp
var HomeView = loop.webapp.HomeView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
var CallUrlExpiredView = loop.webapp.CallUrlExpiredView;
var PendingConversationView = loop.webapp.PendingConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
var StartConversationView = loop.webapp.StartConversationView;
var FailedConversationView = loop.webapp.FailedConversationView;
var EndedConversationView = loop.webapp.EndedConversationView;
// 3. Shared components
var ConversationToolbar = loop.shared.views.ConversationToolbar;
@ -175,8 +176,7 @@
<Example summary="Default" dashed="true" style={{width: "260px", height: "254px"}}>
<div className="fx-embedded" >
<IncomingCallView model={mockConversationModel}
showDeclineMenu={true}
video={true} />
showMenu={true} />
</div>
</Example>
</Section>
@ -252,10 +252,19 @@
<Section name="StartConversationView">
<Example summary="Start conversation view" dashed="true">
<div className="standalone">
<StartConversationView model={mockConversationModel}
<StartConversationView conversation={mockConversationModel}
client={mockClient}
notifications={notifications}
showCallOptionsMenu={true} />
notifications={notifications} />
</div>
</Example>
</Section>
<Section name="FailedConversationView">
<Example summary="Failed conversation view" dashed="true">
<div className="standalone">
<FailedConversationView conversation={mockConversationModel}
client={mockClient}
notifications={notifications} />
</div>
</Example>
</Section>