Bug 1020449 Loop should show caller information on incoming calls. Patch originally by Andrei, updated and polished by Standard8. r=nperriault

This commit is contained in:
Andrei Oprea
2014-10-10 10:19:45 +01:00
parent 7bf1f19f36
commit fea8df91a6
17 changed files with 403 additions and 59 deletions

View File

@@ -15,6 +15,7 @@ loop.conversation = (function(mozL10n) {
var sharedMixins = loop.shared.mixins; var sharedMixins = loop.shared.mixins;
var sharedModels = loop.shared.models; var sharedModels = loop.shared.models;
var OutgoingConversationView = loop.conversationViews.OutgoingConversationView; var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
var CallIdentifierView = loop.conversationViews.CallIdentifierView;
var IncomingCallView = React.createClass({displayName: 'IncomingCallView', var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
mixins: [sharedMixins.DropdownMenuMixin], mixins: [sharedMixins.DropdownMenuMixin],
@@ -94,9 +95,14 @@ loop.conversation = (function(mozL10n) {
"conversation-window-dropdown": true, "conversation-window-dropdown": true,
"visually-hidden": !this.state.showMenu "visually-hidden": !this.state.showMenu
}); });
return ( return (
React.DOM.div({className: "call-window"}, React.DOM.div({className: "call-window"},
React.DOM.h2(null, mozL10n.get("incoming_call_title2")), CallIdentifierView({video: this.props.video,
peerIdentifier: this.props.model.getCallIdentifier(),
urlCreationDate: this.props.model.get("urlCreationDate"),
showIcons: true}),
React.DOM.div({className: "btn-group call-action-group"}, React.DOM.div({className: "btn-group call-action-group"},
React.DOM.div({className: "fx-embedded-call-button-spacer"}), React.DOM.div({className: "fx-embedded-call-button-spacer"}),

View File

@@ -15,6 +15,7 @@ loop.conversation = (function(mozL10n) {
var sharedMixins = loop.shared.mixins; var sharedMixins = loop.shared.mixins;
var sharedModels = loop.shared.models; var sharedModels = loop.shared.models;
var OutgoingConversationView = loop.conversationViews.OutgoingConversationView; var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
var CallIdentifierView = loop.conversationViews.CallIdentifierView;
var IncomingCallView = React.createClass({ var IncomingCallView = React.createClass({
mixins: [sharedMixins.DropdownMenuMixin], mixins: [sharedMixins.DropdownMenuMixin],
@@ -94,9 +95,14 @@ loop.conversation = (function(mozL10n) {
"conversation-window-dropdown": true, "conversation-window-dropdown": true,
"visually-hidden": !this.state.showMenu "visually-hidden": !this.state.showMenu
}); });
return ( return (
<div className="call-window"> <div className="call-window">
<h2>{mozL10n.get("incoming_call_title2")}</h2> <CallIdentifierView video={this.props.video}
peerIdentifier={this.props.model.getCallIdentifier()}
urlCreationDate={this.props.model.get("urlCreationDate")}
showIcons={true} />
<div className="btn-group call-action-group"> <div className="btn-group call-action-group">
<div className="fx-embedded-call-button-spacer"></div> <div className="fx-embedded-call-button-spacer"></div>

View File

@@ -14,6 +14,73 @@ loop.conversationViews = (function(mozL10n) {
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views; var sharedViews = loop.shared.views;
/**
* Displays information about the call
* Caller avatar, name & conversation creation date
*/
var CallIdentifierView = React.createClass({displayName: 'CallIdentifierView',
propTypes: {
peerIdentifier: React.PropTypes.string,
showIcons: React.PropTypes.bool.isRequired,
urlCreationDate: React.PropTypes.string,
video: React.PropTypes.bool
},
getDefaultProps: function() {
return {
peerIdentifier: "",
showLinkDetail: true,
urlCreationDate: "",
video: true
};
},
getInitialState: function() {
return {timestamp: 0};
},
/**
* Gets and formats the incoming call creation date
*/
formatCreationDate: function() {
if (!this.props.urlCreationDate) {
return "";
}
var timestamp = this.props.urlCreationDate;
return "(" + loop.shared.utils.formatDate(timestamp) + ")";
},
render: function() {
var iconVideoClasses = React.addons.classSet({
"fx-embedded-tiny-video-icon": true,
"muted": !this.props.video
});
var callDetailClasses = React.addons.classSet({
"fx-embedded-call-detail": true,
"hide": !this.props.showIcons
});
return (
React.DOM.div({className: "fx-embedded-call-identifier"},
React.DOM.div({className: "fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"}),
React.DOM.div({className: "fx-embedded-call-identifier-info fx-embedded-call-identifier-item"},
React.DOM.div({className: "fx-embedded-call-identifier-text overflow-text-ellipsis font-bold"},
this.props.peerIdentifier
),
React.DOM.div({className: callDetailClasses},
React.DOM.span({className: "fx-embedded-tiny-audio-icon"}),
React.DOM.span({className: iconVideoClasses}),
React.DOM.span({className: "fx-embedded-conversation-timestamp"},
this.formatCreationDate()
)
)
)
)
);
}
});
/** /**
* Displays details of the incoming/outgoing conversation * Displays details of the incoming/outgoing conversation
* (name, link, audio/video type etc). * (name, link, audio/video type etc).
@@ -51,7 +118,9 @@ loop.conversationViews = (function(mozL10n) {
return ( return (
React.DOM.div({className: "call-window"}, React.DOM.div({className: "call-window"},
React.DOM.h2(null, contactName), CallIdentifierView({
peerIdentifier: contactName,
showIcons: false}),
React.DOM.div(null, this.props.children) React.DOM.div(null, this.props.children)
) )
); );
@@ -382,6 +451,7 @@ loop.conversationViews = (function(mozL10n) {
return { return {
PendingConversationView: PendingConversationView, PendingConversationView: PendingConversationView,
CallIdentifierView: CallIdentifierView,
ConversationDetailView: ConversationDetailView, ConversationDetailView: ConversationDetailView,
CallFailedView: CallFailedView, CallFailedView: CallFailedView,
OngoingConversationView: OngoingConversationView, OngoingConversationView: OngoingConversationView,

View File

@@ -14,6 +14,73 @@ loop.conversationViews = (function(mozL10n) {
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedViews = loop.shared.views; var sharedViews = loop.shared.views;
/**
* Displays information about the call
* Caller avatar, name & conversation creation date
*/
var CallIdentifierView = React.createClass({
propTypes: {
peerIdentifier: React.PropTypes.string,
showIcons: React.PropTypes.bool.isRequired,
urlCreationDate: React.PropTypes.string,
video: React.PropTypes.bool
},
getDefaultProps: function() {
return {
peerIdentifier: "",
showLinkDetail: true,
urlCreationDate: "",
video: true
};
},
getInitialState: function() {
return {timestamp: 0};
},
/**
* Gets and formats the incoming call creation date
*/
formatCreationDate: function() {
if (!this.props.urlCreationDate) {
return "";
}
var timestamp = this.props.urlCreationDate;
return "(" + loop.shared.utils.formatDate(timestamp) + ")";
},
render: function() {
var iconVideoClasses = React.addons.classSet({
"fx-embedded-tiny-video-icon": true,
"muted": !this.props.video
});
var callDetailClasses = React.addons.classSet({
"fx-embedded-call-detail": true,
"hide": !this.props.showIcons
});
return (
<div className="fx-embedded-call-identifier">
<div className="fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"/>
<div className="fx-embedded-call-identifier-info fx-embedded-call-identifier-item">
<div className="fx-embedded-call-identifier-text overflow-text-ellipsis>
{this.props.peerIdentifier}
</div>
<div className={callDetailClasses}>
<span className="fx-embedded-tiny-audio-icon"></span>
<span className={iconVideoClasses}></span>
<span className="fx-embedded-conversation-timestamp">
{this.formatCreationDate()}
</span>
</div>
</div>
</div>
);
}
});
/** /**
* Displays details of the incoming/outgoing conversation * Displays details of the incoming/outgoing conversation
* (name, link, audio/video type etc). * (name, link, audio/video type etc).
@@ -51,7 +118,9 @@ loop.conversationViews = (function(mozL10n) {
return ( return (
<div className="call-window"> <div className="call-window">
<h2>{contactName}</h2> <CallIdentifierView
peerIdentifier={contactName}
showIcons={false} />
<div>{this.props.children}</div> <div>{this.props.children}</div>
</div> </div>
); );
@@ -382,6 +451,7 @@ loop.conversationViews = (function(mozL10n) {
return { return {
PendingConversationView: PendingConversationView, PendingConversationView: PendingConversationView,
CallIdentifierView: CallIdentifierView,
ConversationDetailView: ConversationDetailView, ConversationDetailView: ConversationDetailView,
CallFailedView: CallFailedView, CallFailedView: CallFailedView,
OngoingConversationView: OngoingConversationView, OngoingConversationView: OngoingConversationView,

View File

@@ -308,15 +308,6 @@ loop.panel = (function(_, mozL10n) {
this._fetchCallUrl(); this._fetchCallUrl();
}, },
/**
* Returns a random 5 character string used to identify
* the conversation.
* XXX this will go away once the backend changes
*/
conversationIdentifier: function() {
return Math.random().toString(36).substring(5);
},
componentDidMount: function() { componentDidMount: function() {
// If we've already got a callURL, don't bother requesting a new one. // If we've already got a callURL, don't bother requesting a new one.
// As of this writing, only used for visual testing in the UI showcase. // As of this writing, only used for visual testing in the UI showcase.
@@ -332,7 +323,9 @@ loop.panel = (function(_, mozL10n) {
*/ */
_fetchCallUrl: function() { _fetchCallUrl: function() {
this.setState({pending: true}); this.setState({pending: true});
this.props.client.requestCallUrl(this.conversationIdentifier(), // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
// a user-set string.
this.props.client.requestCallUrl("",
this._onCallUrlReceived); this._onCallUrlReceived);
}, },

View File

@@ -308,15 +308,6 @@ loop.panel = (function(_, mozL10n) {
this._fetchCallUrl(); this._fetchCallUrl();
}, },
/**
* Returns a random 5 character string used to identify
* the conversation.
* XXX this will go away once the backend changes
*/
conversationIdentifier: function() {
return Math.random().toString(36).substring(5);
},
componentDidMount: function() { componentDidMount: function() {
// If we've already got a callURL, don't bother requesting a new one. // If we've already got a callURL, don't bother requesting a new one.
// As of this writing, only used for visual testing in the UI showcase. // As of this writing, only used for visual testing in the UI showcase.
@@ -332,7 +323,9 @@ loop.panel = (function(_, mozL10n) {
*/ */
_fetchCallUrl: function() { _fetchCallUrl: function() {
this.setState({pending: true}); this.setState({pending: true});
this.props.client.requestCallUrl(this.conversationIdentifier(), // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
// a user-set string.
this.props.client.requestCallUrl("",
this._onCallUrlReceived); this._onCallUrlReceived);
}, },

View File

@@ -94,12 +94,14 @@
} }
.fx-embedded-btn-icon-video, .fx-embedded-btn-icon-video,
.fx-embedded-btn-video-small { .fx-embedded-btn-video-small,
.fx-embedded-tiny-video-icon {
background-image: url("../img/video-inverse-14x14.png"); background-image: url("../img/video-inverse-14x14.png");
} }
.fx-embedded-btn-icon-audio, .fx-embedded-btn-icon-audio,
.fx-embedded-btn-audio-small { .fx-embedded-btn-audio-small,
.fx-embedded-tiny-audio-icon {
background-image: url("../img/audio-inverse-14x14.png"); background-image: url("../img/audio-inverse-14x14.png");
} }
@@ -484,6 +486,74 @@
min-height: 200px; min-height: 200px;
} }
.fx-embedded-call-identifier {
display: inline;
width: 100%;
padding: 1.2em;
}
.fx-embedded-call-identifier-item {
height: 50px;
}
.fx-embedded-call-identifier-avatar {
max-width: 50px;
min-width: 50px;
background: #ccc;
border-radius: 50%;
background-image: url("../img/audio-call-avatar.svg");
background-repeat: no-repeat;
background-color: #4ba6e7;
background-size: contain;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
float: left;
-moz-margin-end: 1em;
}
.fx-embedded-call-identifier-text {
font-weight: bold;
}
.fx-embedded-call-identifier-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
-moz-margin-start: 1em;
}
.fx-embedded-conversation-timestamp {
font-size: .6rem;
line-height: 17px;
display: inline-block;
vertical-align: top;
}
.fx-embedded-call-detail {
padding-top: 1.2em;
}
.fx-embedded-tiny-video-icon {
margin: 0 0.8em;
}
.fx-embedded-tiny-audio-icon,
.fx-embedded-tiny-video-icon {
width: 18px;
height: 18px;
background-size: 12px 12px;
background-color: #4ba6e7;
display: inline-block;
background-repeat: no-repeat;
background-position: center;
border-radius: 50%;
}
.fx-embedded-tiny-video-icon.muted {
background-color: rgba(0,0,0,.2)
}
@media screen and (min-width:640px) { @media screen and (min-width:640px) {
/* Force full height on all parents up to the video elements /* Force full height on all parents up to the video elements

View File

@@ -32,6 +32,7 @@ loop.shared.models = (function(l10n) {
selectedCallType: "audio-video", // The selected type for the call that was selectedCallType: "audio-video", // The selected type for the call that was
// initiated ("audio" or "audio-video") // initiated ("audio" or "audio-video")
callToken: undefined, // Incoming call token. callToken: undefined, // Incoming call token.
callUrl: undefined, // Incoming call url
// Used for blocking a call url // Used for blocking a call url
subscribedStream: false, // Used to indicate that a stream has been subscribedStream: false, // Used to indicate that a stream has been
// subscribed to // subscribed to
@@ -142,15 +143,18 @@ loop.shared.models = (function(l10n) {
setIncomingSessionData: function(sessionData) { setIncomingSessionData: function(sessionData) {
// Explicit property assignment to prevent later "surprises" // Explicit property assignment to prevent later "surprises"
this.set({ this.set({
sessionId: sessionData.sessionId, sessionId: sessionData.sessionId,
sessionToken: sessionData.sessionToken, sessionToken: sessionData.sessionToken,
sessionType: sessionData.sessionType, sessionType: sessionData.sessionType,
apiKey: sessionData.apiKey, apiKey: sessionData.apiKey,
callId: sessionData.callId, callId: sessionData.callId,
progressURL: sessionData.progressURL, callerId: sessionData.callerId,
websocketToken: sessionData.websocketToken.toString(16), urlCreationDate: sessionData.urlCreationDate,
callType: sessionData.callType || "audio-video", progressURL: sessionData.progressURL,
callToken: sessionData.callToken websocketToken: sessionData.websocketToken.toString(16),
callType: sessionData.callType || "audio-video",
callToken: sessionData.callToken,
callUrl: sessionData.callUrl
}); });
}, },
@@ -198,6 +202,23 @@ loop.shared.models = (function(l10n) {
return undefined; return undefined;
}, },
/**
* Used to remove the scheme from a url.
*/
_removeScheme: function(url) {
if (!url) {
return "";
}
return url.replace(/^https?:\/\//, "");
},
/**
* Returns a conversation identifier for the incoming call view
*/
getCallIdentifier: function() {
return this.get("callerId") || this._removeScheme(this.get("callUrl"));
},
/** /**
* Publishes a local stream. * Publishes a local stream.
* *

View File

@@ -17,6 +17,18 @@ loop.shared.utils = (function() {
AUDIO_ONLY: "audio" AUDIO_ONLY: "audio"
}; };
/**
* Format a given date into an l10n-friendly string.
*
* @param {Integer} The timestamp in seconds to format.
* @return {String} The formatted string.
*/
function formatDate(timestamp) {
var date = (new Date(timestamp * 1000));
var options = {year: "numeric", month: "long", day: "numeric"};
return date.toLocaleDateString(navigator.language, options);
}
/** /**
* Used for adding different styles to the panel * Used for adding different styles to the panel
* @returns {String} Corresponds to the client platform * @returns {String} Corresponds to the client platform
@@ -87,6 +99,7 @@ loop.shared.utils = (function() {
return { return {
CALL_TYPES: CALL_TYPES, CALL_TYPES: CALL_TYPES,
Helper: Helper, Helper: Helper,
formatDate: formatDate,
getTargetPlatform: getTargetPlatform, getTargetPlatform: getTargetPlatform,
getBoolPreference: getBoolPreference getBoolPreference: getBoolPreference
}; };

View File

@@ -446,10 +446,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
if (err) { if (err) {
this.props.notifications.errorL10n("unable_retrieve_call_info"); this.props.notifications.errorL10n("unable_retrieve_call_info");
} else { } else {
var date = (new Date(callUrlInfo.urlCreationDate * 1000)); this.setState({
var options = {year: "numeric", month: "long", day: "numeric"}; urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate)
var timestamp = date.toLocaleDateString(navigator.language, options); });
this.setState({urlCreationDateString: timestamp});
} }
}, },

View File

@@ -446,10 +446,9 @@ loop.webapp = (function($, _, OT, mozL10n) {
if (err) { if (err) {
this.props.notifications.errorL10n("unable_retrieve_call_info"); this.props.notifications.errorL10n("unable_retrieve_call_info");
} else { } else {
var date = (new Date(callUrlInfo.urlCreationDate * 1000)); this.setState({
var options = {year: "numeric", month: "long", day: "numeric"}; urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate)
var timestamp = date.toLocaleDateString(navigator.language, options); });
this.setState({urlCreationDateString: timestamp});
} }
}, },

View File

@@ -35,6 +35,67 @@ describe("loop.conversationViews", function () {
sandbox.restore(); sandbox.restore();
}); });
describe("CallIdentifierView", function() {
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
loop.conversationViews.CallIdentifierView(props));
}
it("should set display the peer identifer", function() {
view = mountTestComponent({
showIcons: false,
peerIdentifier: "mrssmith"
});
expect(TestUtils.findRenderedDOMComponentWithClass(
view, "fx-embedded-call-identifier-text").props.children).eql("mrssmith");
});
it("should not display the icons if showIcons is false", function() {
view = mountTestComponent({
showIcons: false,
peerIdentifier: "mrssmith"
});
expect(TestUtils.findRenderedDOMComponentWithClass(
view, "fx-embedded-call-detail").props.className).to.contain("hide");
});
it("should display the icons if showIcons is true", function() {
view = mountTestComponent({
showIcons: true,
peerIdentifier: "mrssmith"
});
expect(TestUtils.findRenderedDOMComponentWithClass(
view, "fx-embedded-call-detail").props.className).to.not.contain("hide");
});
it("should display the url timestamp", function() {
sandbox.stub(loop.shared.utils, "formatDate").returns(("October 9, 2014"));
view = mountTestComponent({
showIcons: true,
peerIdentifier: "mrssmith",
urlCreationDate: (new Date() / 1000).toString()
});
expect(TestUtils.findRenderedDOMComponentWithClass(
view, "fx-embedded-conversation-timestamp").props.children).eql("(October 9, 2014)");
});
it("should show video as muted if video is false", function() {
view = mountTestComponent({
showIcons: true,
peerIdentifier: "mrssmith",
video: false
});
expect(TestUtils.findRenderedDOMComponentWithClass(
view, "fx-embedded-tiny-video-icon").props.className).to.contain("muted");
});
});
describe("ConversationDetailView", function() { describe("ConversationDetailView", function() {
function mountTestComponent(props) { function mountTestComponent(props) {
return TestUtils.renderIntoDocument( return TestUtils.renderIntoDocument(
@@ -47,23 +108,14 @@ describe("loop.conversationViews", function () {
expect(document.title).eql("mrsmith"); expect(document.title).eql("mrsmith");
}); });
it("should set display the calledId", function() {
view = mountTestComponent({contact: contact});
expect(TestUtils.findRenderedDOMComponentWithTag(
view, "h2").props.children).eql("mrsmith");
});
it("should fallback to the email if the contact name is not defined", it("should fallback to the email if the contact name is not defined",
function() { function() {
delete contact.name; delete contact.name;
view = mountTestComponent({contact: contact}); mountTestComponent({contact: contact});
expect(TestUtils.findRenderedDOMComponentWithTag( expect(document.title).eql("fakeEmail");
view, "h2").props.children).eql("fakeEmail"); });
}
);
}); });
describe("PendingConversationView", function() { describe("PendingConversationView", function() {

View File

@@ -696,7 +696,9 @@ describe("loop.conversation", function() {
var view, model; var view, model;
beforeEach(function() { beforeEach(function() {
var Model = Backbone.Model.extend({}); var Model = Backbone.Model.extend({
getCallIdentifier: function() {return "fakeId";}
});
model = new Model(); model = new Model();
sandbox.spy(model, "trigger"); sandbox.spy(model, "trigger");
sandbox.stub(model, "set"); sandbox.stub(model, "set");

View File

@@ -27,7 +27,9 @@ describe("loop.shared.models", function() {
apiKey: "apiKey", apiKey: "apiKey",
callType: "callType", callType: "callType",
websocketToken: 123, websocketToken: 123,
callToken: "callToken" callToken: "callToken",
callUrl: "http://invalid/callToken",
callerId: "mrssmith"
}; };
fakeSession = _.extend({ fakeSession = _.extend({
connect: function () {}, connect: function () {},
@@ -360,6 +362,28 @@ describe("loop.shared.models", function() {
expect(model.hasVideoStream("outgoing")).to.eql(true); expect(model.hasVideoStream("outgoing")).to.eql(true);
}); });
}); });
describe("#getCallIdentifier", function() {
var model;
beforeEach(function() {
model = new sharedModels.ConversationModel(fakeSessionData, {
sdk: fakeSDK
});
model.startSession();
});
it("should return the callerId", function() {
expect(model.getCallIdentifier()).to.eql("mrssmith");
});
it("should return the shorted callUrl if the callerId does not exist",
function() {
model.set({"callerId": ""});
expect(model.getCallIdentifier()).to.eql("invalid/callToken");
});
});
}); });
}); });

View File

@@ -88,6 +88,26 @@ describe("loop.shared.utils", function() {
}); });
}); });
describe("#formatDate", function() {
beforeEach(function() {
sandbox.stub(Date.prototype, "toLocaleDateString").returns("fake result");
});
it("should call toLocaleDateString with arguments", function() {
sharedUtils.formatDate(1000);
sinon.assert.calledOnce(Date.prototype.toLocaleDateString);
sinon.assert.calledWithExactly(Date.prototype.toLocaleDateString,
navigator.language,
{year: "numeric", month: "long", day: "numeric"}
);
});
it("should return the formatted string", function() {
expect(sharedUtils.formatDate(1000)).eql("fake result");
});
});
describe("#getBoolPreference", function() { describe("#getBoolPreference", function() {
afterEach(function() { afterEach(function() {
navigator.mozLoop = undefined; navigator.mozLoop = undefined;

View File

@@ -78,7 +78,10 @@
var mockSDK = {}; var mockSDK = {};
var mockConversationModel = new loop.shared.models.ConversationModel({}, { var mockConversationModel = new loop.shared.models.ConversationModel({
callerId: "Mrs Jones",
urlCreationDate: (new Date() / 1000).toString()
}, {
sdk: mockSDK sdk: mockSDK
}); });
mockConversationModel.startSession = noop; mockConversationModel.startSession = noop;

View File

@@ -78,7 +78,10 @@
var mockSDK = {}; var mockSDK = {};
var mockConversationModel = new loop.shared.models.ConversationModel({}, { var mockConversationModel = new loop.shared.models.ConversationModel({
callerId: "Mrs Jones",
urlCreationDate: (new Date() / 1000).toString()
}, {
sdk: mockSDK sdk: mockSDK
}); });
mockConversationModel.startSession = noop; mockConversationModel.startSession = noop;