Bug 1048162 Part 1 - Add an 'Email Link' button to Loop desktop failed call view. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-10-16 18:58:59 +01:00
Родитель 86289da8b5
Коммит 9b7f6776df
13 изменённых файлов: 312 добавлений и 78 удалений

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

@ -601,15 +601,18 @@ function injectLoopAPI(targetWindow) {
/**
* Composes an email via the external protocol service.
*
* @param {String} subject Subject of the email to send
* @param {String} body Body message of the email to send
* @param {String} subject Subject of the email to send
* @param {String} body Body message of the email to send
* @param {String} recipient Recipient email address (optional)
*/
composeEmail: {
enumerable: true,
writable: true,
value: function(subject, body) {
let mailtoURL = "mailto:?subject=" + encodeURIComponent(subject) + "&" +
"body=" + encodeURIComponent(body);
value: function(subject, body, recipient) {
recipient = recipient || "";
let mailtoURL = "mailto:" + encodeURIComponent(recipient) +
"?subject=" + encodeURIComponent(subject) +
"&body=" + encodeURIComponent(body);
extProtocolSvc.loadURI(CommonUtils.makeURI(mailtoURL));
}
},

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

@ -12,8 +12,20 @@ loop.conversationViews = (function(mozL10n) {
var CALL_STATES = loop.store.CALL_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
// 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.
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(e => e.pref) || contact.email[0];
}
/**
* Displays information about the call
* Caller avatar, name & conversation creation date
@ -93,17 +105,6 @@ loop.conversationViews = (function(mozL10n) {
contact: React.PropTypes.object
},
// 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.
_getPreferredEmail: function(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(e => e.pref) || contact.email[0];
},
render: function() {
var contactName;
@ -111,7 +112,7 @@ loop.conversationViews = (function(mozL10n) {
this.props.contact.name[0]) {
contactName = this.props.contact.name[0];
} else {
contactName = this._getPreferredEmail(this.props.contact).value;
contactName = _getPreferredEmail(this.props.contact).value;
}
document.title = contactName;
@ -187,8 +188,33 @@ loop.conversationViews = (function(mozL10n) {
* Call failed view. Displayed when a call fails.
*/
var CallFailedView = React.createClass({displayName: 'CallFailedView',
mixins: [Backbone.Events],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired,
contact: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {emailLinkButtonDisabled: false};
},
componentDidMount: function() {
this.listenTo(this.props.store, "change:emailLink",
this._onEmailLinkReceived);
},
componentWillUnmount: function() {
this.stopListening(this.props.store);
},
_onEmailLinkReceived: function() {
var emailLink = this.props.store.get("emailLink");
var contactEmail = _getPreferredEmail(this.props.contact).value;
sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
window.close();
},
retryCall: function() {
@ -199,25 +225,33 @@ loop.conversationViews = (function(mozL10n) {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
},
emailLink: function() {
this.setState({emailLinkButtonDisabled: true});
this.props.dispatcher.dispatch(new sharedActions.FetchEmailLink());
},
render: function() {
return (
React.DOM.div({className: "call-window"},
React.DOM.h2(null, mozL10n.get("generic_failure_title")),
React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_no_reason2")),
React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_with_reason2")),
React.DOM.div({className: "btn-group call-action-group"},
React.DOM.div({className: "fx-embedded-call-button-spacer"}),
React.DOM.button({className: "btn btn-accept btn-retry",
onClick: this.retryCall},
mozL10n.get("retry_call_button")
),
React.DOM.div({className: "fx-embedded-call-button-spacer"}),
React.DOM.button({className: "btn btn-cancel",
onClick: this.cancelCall},
mozL10n.get("cancel_button")
),
React.DOM.div({className: "fx-embedded-call-button-spacer"})
React.DOM.button({className: "btn btn-cancel",
onClick: this.cancelCall},
mozL10n.get("cancel_button")
),
React.DOM.button({className: "btn btn-info btn-retry",
onClick: this.retryCall},
mozL10n.get("retry_call_button")
),
React.DOM.button({className: "btn btn-info btn-email",
onClick: this.emailLink,
disabled: this.state.emailLinkButtonDisabled},
mozL10n.get("share_button2")
)
)
)
);
@ -425,7 +459,9 @@ loop.conversationViews = (function(mozL10n) {
}
case CALL_STATES.TERMINATED: {
return (CallFailedView({
dispatcher: this.props.dispatcher}
dispatcher: this.props.dispatcher,
store: this.props.store,
contact: this.state.contact}
));
}
case CALL_STATES.ONGOING: {
@ -445,7 +481,7 @@ loop.conversationViews = (function(mozL10n) {
callState: this.state.callState,
contact: this.state.contact,
enableCancelButton: this._isCancellable()}
))
));
}
}
},

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

@ -12,8 +12,20 @@ loop.conversationViews = (function(mozL10n) {
var CALL_STATES = loop.store.CALL_STATES;
var CALL_TYPES = loop.shared.utils.CALL_TYPES;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var sharedViews = loop.shared.views;
// 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.
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(e => e.pref) || contact.email[0];
}
/**
* Displays information about the call
* Caller avatar, name & conversation creation date
@ -93,17 +105,6 @@ loop.conversationViews = (function(mozL10n) {
contact: React.PropTypes.object
},
// 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.
_getPreferredEmail: function(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(e => e.pref) || contact.email[0];
},
render: function() {
var contactName;
@ -111,7 +112,7 @@ loop.conversationViews = (function(mozL10n) {
this.props.contact.name[0]) {
contactName = this.props.contact.name[0];
} else {
contactName = this._getPreferredEmail(this.props.contact).value;
contactName = _getPreferredEmail(this.props.contact).value;
}
document.title = contactName;
@ -187,8 +188,33 @@ loop.conversationViews = (function(mozL10n) {
* Call failed view. Displayed when a call fails.
*/
var CallFailedView = React.createClass({
mixins: [Backbone.Events],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
loop.store.ConversationStore).isRequired,
contact: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {emailLinkButtonDisabled: false};
},
componentDidMount: function() {
this.listenTo(this.props.store, "change:emailLink",
this._onEmailLinkReceived);
},
componentWillUnmount: function() {
this.stopListening(this.props.store);
},
_onEmailLinkReceived: function() {
var emailLink = this.props.store.get("emailLink");
var contactEmail = _getPreferredEmail(this.props.contact).value;
sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
window.close();
},
retryCall: function() {
@ -199,25 +225,33 @@ loop.conversationViews = (function(mozL10n) {
this.props.dispatcher.dispatch(new sharedActions.CancelCall());
},
emailLink: function() {
this.setState({emailLinkButtonDisabled: true});
this.props.dispatcher.dispatch(new sharedActions.FetchEmailLink());
},
render: function() {
return (
<div className="call-window">
<h2>{mozL10n.get("generic_failure_title")}</h2>
<p className="btn-label">{mozL10n.get("generic_failure_no_reason2")}</p>
<p className="btn-label">{mozL10n.get("generic_failure_with_reason2")}</p>
<div className="btn-group call-action-group">
<div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-accept btn-retry"
onClick={this.retryCall}>
{mozL10n.get("retry_call_button")}
</button>
<div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-cancel"
onClick={this.cancelCall}>
{mozL10n.get("cancel_button")}
</button>
<div className="fx-embedded-call-button-spacer"></div>
<button className="btn btn-cancel"
onClick={this.cancelCall}>
{mozL10n.get("cancel_button")}
</button>
<button className="btn btn-info btn-retry"
onClick={this.retryCall}>
{mozL10n.get("retry_call_button")}
</button>
<button className="btn btn-info btn-email"
onClick={this.emailLink}
disabled={this.state.emailLinkButtonDisabled}>
{mozL10n.get("share_button2")}
</button>
</div>
</div>
);
@ -426,6 +460,8 @@ loop.conversationViews = (function(mozL10n) {
case CALL_STATES.TERMINATED: {
return (<CallFailedView
dispatcher={this.props.dispatcher}
store={this.props.store}
contact={this.state.contact}
/>);
}
case CALL_STATES.ONGOING: {
@ -445,7 +481,7 @@ loop.conversationViews = (function(mozL10n) {
callState={this.state.callState}
contact={this.state.contact}
enableCancelButton={this._isCancellable()}
/>)
/>);
}
}
},

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

@ -15,6 +15,7 @@ loop.panel = (function(_, mozL10n) {
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var Button = sharedViews.Button;
var ButtonGroup = sharedViews.ButtonGroup;
var ContactsList = loop.contacts.ContactsList;
@ -362,11 +363,7 @@ loop.panel = (function(_, mozL10n) {
handleEmailButtonClick: function(event) {
this.handleLinkExfiltration(event);
navigator.mozLoop.composeEmail(
__("share_email_subject4", { clientShortname: __("clientShortname2")}),
__("share_email_body4", { callUrl: this.state.callUrl,
clientShortname: __("clientShortname2"),
learnMoreUrl: navigator.mozLoop.getLoopCharPref("learnMoreUrl") }));
sharedUtils.composeCallUrlEmail(this.state.callUrl);
},
handleCopyButtonClick: function(event) {

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

@ -15,6 +15,7 @@ loop.panel = (function(_, mozL10n) {
var sharedModels = loop.shared.models;
var sharedMixins = loop.shared.mixins;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
var Button = sharedViews.Button;
var ButtonGroup = sharedViews.ButtonGroup;
var ContactsList = loop.contacts.ContactsList;
@ -362,11 +363,7 @@ loop.panel = (function(_, mozL10n) {
handleEmailButtonClick: function(event) {
this.handleLinkExfiltration(event);
navigator.mozLoop.composeEmail(
__("share_email_subject4", { clientShortname: __("clientShortname2")}),
__("share_email_body4", { callUrl: this.state.callUrl,
clientShortname: __("clientShortname2"),
learnMoreUrl: navigator.mozLoop.getLoopCharPref("learnMoreUrl") }));
sharedUtils.composeCallUrlEmail(this.state.callUrl);
},
handleCopyButtonClick: function(event) {

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

@ -240,16 +240,21 @@
min-height: 230px;
}
.call-window > .btn-label {
text-align: center;
}
.call-action-group {
display: flex;
padding: 2.5em 0 0 0;
padding: 2.5em 4px 0 4px;
width: 100%;
justify-content: space-around;
}
.call-action-group > .btn {
margin-left: .5em;
height: 26px;
border-radius: 2px;
margin: 0 4px;
min-width: 64px;
}
.call-action-group .btn-group-chevron,

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

@ -30,6 +30,13 @@ loop.shared.actions = (function() {
};
return {
/**
* Fetch a new call url from the server, intended to be sent over email when
* a contact can't be reached.
*/
FetchEmailLink: Action.define("fetchEmailLink", {
}),
/**
* Used to trigger gathering of initial call data.
*/

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

@ -126,7 +126,8 @@ loop.store.ConversationStore = (function() {
"cancelCall",
"retryCall",
"mediaConnected",
"setMute"
"setMute",
"fetchEmailLink"
]);
},
@ -303,6 +304,23 @@ loop.store.ConversationStore = (function() {
this.set(muteType, !actionData.enabled);
},
/**
* Fetches a new call URL intended to be sent over email when a contact
* can't be reached.
*/
fetchEmailLink: function() {
// XXX This is an empty string as a conversation identifier. Bug 1015938 implements
// a user-set string.
this.client.requestCallUrl("", function(err, callUrlData) {
if (err) {
// XXX better error reporting in the UI
console.error(err);
return;
}
this.set("emailLink", callUrlData.callUrl);
}.bind(this));
},
/**
* Obtains the outgoing call data from the server and handles the
* result.

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

@ -6,7 +6,7 @@
var loop = loop || {};
loop.shared = loop.shared || {};
loop.shared.utils = (function() {
loop.shared.utils = (function(mozL10n) {
"use strict";
/**
@ -96,11 +96,37 @@ loop.shared.utils = (function() {
}
};
/**
* Generates and opens a mailto: url with call URL information prefilled.
* Note: This only works for Desktop.
*
* @param {String} callUrl The call URL.
* @param {String} recipient The recipient email address (optional).
*/
function composeCallUrlEmail(callUrl, recipient) {
if (typeof navigator.mozLoop === "undefined") {
console.warn("composeCallUrlEmail isn't available for Loop standalone.");
return;
}
navigator.mozLoop.composeEmail(
mozL10n.get("share_email_subject4", {
clientShortname: mozL10n.get("clientShortname2")
}),
mozL10n.get("share_email_body4", {
callUrl: callUrl,
clientShortname: mozL10n.get("clientShortname2"),
learnMoreUrl: navigator.mozLoop.getLoopCharPref("learnMoreUrl")
}),
recipient
);
}
return {
CALL_TYPES: CALL_TYPES,
Helper: Helper,
composeCallUrlEmail: composeCallUrlEmail,
formatDate: formatDate,
getTargetPlatform: getTargetPlatform,
getBoolPreference: getBoolPreference
};
})();
})(document.mozL10n || navigator.mozL10n);

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

@ -6,6 +6,7 @@ var expect = chai.expect;
describe("loop.conversationViews", function () {
"use strict";
var sharedUtils = loop.shared.utils;
var sandbox, oldTitle, view, dispatcher, contact;
var CALL_STATES = loop.store.CALL_STATES;
@ -201,13 +202,25 @@ describe("loop.conversationViews", function () {
});
describe("CallFailedView", function() {
var store;
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
loop.conversationViews.CallFailedView({
dispatcher: dispatcher
dispatcher: dispatcher,
store: store,
contact: {email: [{value: "test@test.tld"}]}
}));
}
beforeEach(function() {
store = new loop.store.ConversationStore({}, {
dispatcher: dispatcher,
client: {},
sdkDriver: {}
});
});
it("should dispatch a retryCall action when the retry button is pressed",
function() {
view = mountTestComponent();
@ -233,6 +246,48 @@ describe("loop.conversationViews", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "cancelCall"));
});
it("should dispatch a fetchEmailLink action when the cancel 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", "fetchEmailLink"));
});
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() {
var composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
view = mountTestComponent();
store.set("emailLink", "http://fake.invalid/");
sinon.assert.calledOnce(composeCallUrlEmail);
sinon.assert.calledWithExactly(composeCallUrlEmail,
"http://fake.invalid/", "test@test.tld");
});
it("should close the conversation window once the email link is received",
function() {
sandbox.stub(window, "close");
view = mountTestComponent();
store.set("emailLink", "http://fake.invalid/");
sinon.assert.calledOnce(window.close);
});
});
describe("OngoingConversationView", function() {

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

@ -8,6 +8,7 @@
var expect = chai.expect;
var TestUtils = React.addons.TestUtils;
var sharedActions = loop.shared.actions;
var sharedUtils = loop.shared.utils;
describe("loop.panel", function() {
"use strict";
@ -449,6 +450,7 @@ describe("loop.panel", function() {
it("should display a share button for email", function() {
fakeClient.requestCallUrl = sandbox.stub();
var composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
notifications: notifications,
client: fakeClient
@ -457,7 +459,9 @@ describe("loop.panel", function() {
TestUtils.findRenderedDOMComponentWithClass(view, "button-email");
TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
sinon.assert.calledOnce(navigator.mozLoop.composeEmail);
sinon.assert.calledOnce(composeCallUrlEmail);
sinon.assert.calledWithExactly(composeCallUrlEmail, "http://example.com");
});
it("should feature a copy button capable of copying the call url when clicked", function() {

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

@ -38,7 +38,8 @@ describe("loop.store.ConversationStore", function () {
dispatcher = new loop.Dispatcher();
client = {
setupOutgoingCall: sinon.stub()
setupOutgoingCall: sinon.stub(),
requestCallUrl: sinon.stub()
};
sdkDriver = {
connectSession: sinon.stub(),
@ -566,6 +567,28 @@ describe("loop.store.ConversationStore", function () {
});
});
describe("#fetchEmailLink", function() {
it("should request a new call url to the server", function() {
dispatcher.dispatch(new sharedActions.FetchEmailLink());
sinon.assert.calledOnce(client.requestCallUrl);
sinon.assert.calledWith(client.requestCallUrl, "");
});
it("should update the emailLink attribute when the new call url is received",
function() {
client.requestCallUrl = function(callId, cb) {
cb(null, {callUrl: "http://fake.invalid/"});
};
dispatcher.dispatch(new sharedActions.FetchEmailLink());
expect(store.get("emailLink")).eql("http://fake.invalid/");
});
// XXX bug 1048162 Part 2
it.skip("should trigger an error in case of failure");
});
describe("Events", function() {
describe("Websocket progress", function() {
beforeEach(function() {

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

@ -18,6 +18,7 @@ describe("loop.shared.utils", function() {
});
afterEach(function() {
navigator.mozLoop = undefined;
sandbox.restore();
});
@ -110,7 +111,6 @@ describe("loop.shared.utils", function() {
describe("#getBoolPreference", function() {
afterEach(function() {
navigator.mozLoop = undefined;
localStorage.removeItem("test.true");
});
@ -142,4 +142,31 @@ describe("loop.shared.utils", function() {
});
});
});
describe("#composeCallUrlEmail", function() {
var composeEmail;
beforeEach(function() {
// fake mozL10n
sandbox.stub(navigator.mozL10n, "get", function(id) {
switch(id) {
case "share_email_subject4": return "subject";
case "share_email_body4": return "body";
}
});
composeEmail = sandbox.spy();
navigator.mozLoop = {
getLoopCharPref: sandbox.spy(),
composeEmail: composeEmail
};
});
it("should compose a call url email", function() {
sharedUtils.composeCallUrlEmail("http://invalid", "fake@invalid.tld");
sinon.assert.calledOnce(composeEmail);
sinon.assert.calledWith(composeEmail,
"subject", "body", "fake@invalid.tld");
});
});
});