diff --git a/browser/components/loop/content/js/conversation.js b/browser/components/loop/content/js/conversation.js index 93544dcc50e4..517be708ff57 100644 --- a/browser/components/loop/content/js/conversation.js +++ b/browser/components/loop/content/js/conversation.js @@ -15,6 +15,7 @@ loop.conversation = (function(mozL10n) { var sharedMixins = loop.shared.mixins; var sharedModels = loop.shared.models; var OutgoingConversationView = loop.conversationViews.OutgoingConversationView; + var CallIdentifierView = loop.conversationViews.CallIdentifierView; var IncomingCallView = React.createClass({displayName: 'IncomingCallView', mixins: [sharedMixins.DropdownMenuMixin], @@ -94,9 +95,14 @@ loop.conversation = (function(mozL10n) { "conversation-window-dropdown": true, "visually-hidden": !this.state.showMenu }); + return ( 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: "fx-embedded-call-button-spacer"}), diff --git a/browser/components/loop/content/js/conversation.jsx b/browser/components/loop/content/js/conversation.jsx index 2cead003a828..e12784753ab1 100644 --- a/browser/components/loop/content/js/conversation.jsx +++ b/browser/components/loop/content/js/conversation.jsx @@ -15,6 +15,7 @@ loop.conversation = (function(mozL10n) { var sharedMixins = loop.shared.mixins; var sharedModels = loop.shared.models; var OutgoingConversationView = loop.conversationViews.OutgoingConversationView; + var CallIdentifierView = loop.conversationViews.CallIdentifierView; var IncomingCallView = React.createClass({ mixins: [sharedMixins.DropdownMenuMixin], @@ -94,9 +95,14 @@ loop.conversation = (function(mozL10n) { "conversation-window-dropdown": true, "visually-hidden": !this.state.showMenu }); + return (
-

{mozL10n.get("incoming_call_title2")}

+ +
diff --git a/browser/components/loop/content/js/conversationViews.js b/browser/components/loop/content/js/conversationViews.js index d6f007edb733..7b15af74916b 100644 --- a/browser/components/loop/content/js/conversationViews.js +++ b/browser/components/loop/content/js/conversationViews.js @@ -14,6 +14,73 @@ loop.conversationViews = (function(mozL10n) { var sharedActions = loop.shared.actions; 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 * (name, link, audio/video type etc). @@ -51,7 +118,9 @@ loop.conversationViews = (function(mozL10n) { return ( React.DOM.div({className: "call-window"}, - React.DOM.h2(null, contactName), + CallIdentifierView({ + peerIdentifier: contactName, + showIcons: false}), React.DOM.div(null, this.props.children) ) ); @@ -382,6 +451,7 @@ loop.conversationViews = (function(mozL10n) { return { PendingConversationView: PendingConversationView, + CallIdentifierView: CallIdentifierView, ConversationDetailView: ConversationDetailView, CallFailedView: CallFailedView, OngoingConversationView: OngoingConversationView, diff --git a/browser/components/loop/content/js/conversationViews.jsx b/browser/components/loop/content/js/conversationViews.jsx index 26ca7afd7639..18822b3f6564 100644 --- a/browser/components/loop/content/js/conversationViews.jsx +++ b/browser/components/loop/content/js/conversationViews.jsx @@ -14,6 +14,73 @@ loop.conversationViews = (function(mozL10n) { var sharedActions = loop.shared.actions; 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 ( +
+
+
+
+ + + {this.formatCreationDate()} + +
+
+
+ ); + } + }); + /** * Displays details of the incoming/outgoing conversation * (name, link, audio/video type etc). @@ -51,7 +118,9 @@ loop.conversationViews = (function(mozL10n) { return (
-

{contactName}

+
{this.props.children}
); @@ -382,6 +451,7 @@ loop.conversationViews = (function(mozL10n) { return { PendingConversationView: PendingConversationView, + CallIdentifierView: CallIdentifierView, ConversationDetailView: ConversationDetailView, CallFailedView: CallFailedView, OngoingConversationView: OngoingConversationView, diff --git a/browser/components/loop/content/js/panel.js b/browser/components/loop/content/js/panel.js index 422246d004ed..31cf0ad2d8c8 100644 --- a/browser/components/loop/content/js/panel.js +++ b/browser/components/loop/content/js/panel.js @@ -308,15 +308,6 @@ loop.panel = (function(_, mozL10n) { 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() { // 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. @@ -332,7 +323,9 @@ loop.panel = (function(_, mozL10n) { */ _fetchCallUrl: function() { 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); }, diff --git a/browser/components/loop/content/js/panel.jsx b/browser/components/loop/content/js/panel.jsx index d8c397820230..ea8d7c116c4f 100644 --- a/browser/components/loop/content/js/panel.jsx +++ b/browser/components/loop/content/js/panel.jsx @@ -308,15 +308,6 @@ loop.panel = (function(_, mozL10n) { 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() { // 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. @@ -332,7 +323,9 @@ loop.panel = (function(_, mozL10n) { */ _fetchCallUrl: function() { 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); }, diff --git a/browser/components/loop/content/shared/css/conversation.css b/browser/components/loop/content/shared/css/conversation.css index ecdb34bd115b..a33cc20ec13c 100644 --- a/browser/components/loop/content/shared/css/conversation.css +++ b/browser/components/loop/content/shared/css/conversation.css @@ -94,12 +94,14 @@ } .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"); } .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"); } @@ -484,6 +486,74 @@ 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) { /* Force full height on all parents up to the video elements diff --git a/browser/components/loop/content/shared/js/models.js b/browser/components/loop/content/shared/js/models.js index f7c2b24bd009..c0e653c9fdc7 100644 --- a/browser/components/loop/content/shared/js/models.js +++ b/browser/components/loop/content/shared/js/models.js @@ -32,6 +32,7 @@ loop.shared.models = (function(l10n) { selectedCallType: "audio-video", // The selected type for the call that was // initiated ("audio" or "audio-video") callToken: undefined, // Incoming call token. + callUrl: undefined, // Incoming call url // Used for blocking a call url subscribedStream: false, // Used to indicate that a stream has been // subscribed to @@ -142,15 +143,18 @@ loop.shared.models = (function(l10n) { setIncomingSessionData: function(sessionData) { // Explicit property assignment to prevent later "surprises" this.set({ - sessionId: sessionData.sessionId, - sessionToken: sessionData.sessionToken, - sessionType: sessionData.sessionType, - apiKey: sessionData.apiKey, - callId: sessionData.callId, - progressURL: sessionData.progressURL, - websocketToken: sessionData.websocketToken.toString(16), - callType: sessionData.callType || "audio-video", - callToken: sessionData.callToken + sessionId: sessionData.sessionId, + sessionToken: sessionData.sessionToken, + sessionType: sessionData.sessionType, + apiKey: sessionData.apiKey, + callId: sessionData.callId, + callerId: sessionData.callerId, + urlCreationDate: sessionData.urlCreationDate, + progressURL: sessionData.progressURL, + 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; }, + /** + * 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. * diff --git a/browser/components/loop/content/shared/js/utils.js b/browser/components/loop/content/shared/js/utils.js index 09f004054b63..fc1d2b1f2582 100644 --- a/browser/components/loop/content/shared/js/utils.js +++ b/browser/components/loop/content/shared/js/utils.js @@ -17,6 +17,18 @@ loop.shared.utils = (function() { 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 * @returns {String} Corresponds to the client platform @@ -87,6 +99,7 @@ loop.shared.utils = (function() { return { CALL_TYPES: CALL_TYPES, Helper: Helper, + formatDate: formatDate, getTargetPlatform: getTargetPlatform, getBoolPreference: getBoolPreference }; diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js index e08f9d52da44..e8831895db56 100644 --- a/browser/components/loop/standalone/content/js/webapp.js +++ b/browser/components/loop/standalone/content/js/webapp.js @@ -446,10 +446,9 @@ loop.webapp = (function($, _, OT, mozL10n) { if (err) { this.props.notifications.errorL10n("unable_retrieve_call_info"); } else { - var date = (new Date(callUrlInfo.urlCreationDate * 1000)); - var options = {year: "numeric", month: "long", day: "numeric"}; - var timestamp = date.toLocaleDateString(navigator.language, options); - this.setState({urlCreationDateString: timestamp}); + this.setState({ + urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate) + }); } }, diff --git a/browser/components/loop/standalone/content/js/webapp.jsx b/browser/components/loop/standalone/content/js/webapp.jsx index 8d2e92e0c254..7c08edbcbda0 100644 --- a/browser/components/loop/standalone/content/js/webapp.jsx +++ b/browser/components/loop/standalone/content/js/webapp.jsx @@ -446,10 +446,9 @@ loop.webapp = (function($, _, OT, mozL10n) { if (err) { this.props.notifications.errorL10n("unable_retrieve_call_info"); } else { - var date = (new Date(callUrlInfo.urlCreationDate * 1000)); - var options = {year: "numeric", month: "long", day: "numeric"}; - var timestamp = date.toLocaleDateString(navigator.language, options); - this.setState({urlCreationDateString: timestamp}); + this.setState({ + urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate) + }); } }, diff --git a/browser/components/loop/test/desktop-local/conversationViews_test.js b/browser/components/loop/test/desktop-local/conversationViews_test.js index 4e6394525a94..d1fe0925599a 100644 --- a/browser/components/loop/test/desktop-local/conversationViews_test.js +++ b/browser/components/loop/test/desktop-local/conversationViews_test.js @@ -35,6 +35,67 @@ describe("loop.conversationViews", function () { 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() { function mountTestComponent(props) { return TestUtils.renderIntoDocument( @@ -47,23 +108,14 @@ describe("loop.conversationViews", function () { 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", function() { delete contact.name; - view = mountTestComponent({contact: contact}); + mountTestComponent({contact: contact}); - expect(TestUtils.findRenderedDOMComponentWithTag( - view, "h2").props.children).eql("fakeEmail"); - } - ); + expect(document.title).eql("fakeEmail"); + }); }); describe("PendingConversationView", function() { diff --git a/browser/components/loop/test/desktop-local/conversation_test.js b/browser/components/loop/test/desktop-local/conversation_test.js index 7d413f200247..5c211e9103a4 100644 --- a/browser/components/loop/test/desktop-local/conversation_test.js +++ b/browser/components/loop/test/desktop-local/conversation_test.js @@ -696,7 +696,9 @@ describe("loop.conversation", function() { var view, model; beforeEach(function() { - var Model = Backbone.Model.extend({}); + var Model = Backbone.Model.extend({ + getCallIdentifier: function() {return "fakeId";} + }); model = new Model(); sandbox.spy(model, "trigger"); sandbox.stub(model, "set"); diff --git a/browser/components/loop/test/shared/models_test.js b/browser/components/loop/test/shared/models_test.js index 53998b70f97f..66db6a67a462 100644 --- a/browser/components/loop/test/shared/models_test.js +++ b/browser/components/loop/test/shared/models_test.js @@ -27,7 +27,9 @@ describe("loop.shared.models", function() { apiKey: "apiKey", callType: "callType", websocketToken: 123, - callToken: "callToken" + callToken: "callToken", + callUrl: "http://invalid/callToken", + callerId: "mrssmith" }; fakeSession = _.extend({ connect: function () {}, @@ -360,6 +362,28 @@ describe("loop.shared.models", function() { 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"); + }); + }); }); }); diff --git a/browser/components/loop/test/shared/utils_test.js b/browser/components/loop/test/shared/utils_test.js index ee01efbbb1d6..c46674fca14d 100644 --- a/browser/components/loop/test/shared/utils_test.js +++ b/browser/components/loop/test/shared/utils_test.js @@ -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() { afterEach(function() { navigator.mozLoop = undefined; diff --git a/browser/components/loop/ui/ui-showcase.js b/browser/components/loop/ui/ui-showcase.js index 68fc3ec258e3..d04b11f98a68 100644 --- a/browser/components/loop/ui/ui-showcase.js +++ b/browser/components/loop/ui/ui-showcase.js @@ -78,7 +78,10 @@ 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 }); mockConversationModel.startSession = noop; diff --git a/browser/components/loop/ui/ui-showcase.jsx b/browser/components/loop/ui/ui-showcase.jsx index f642005c3a5d..dafe37a6dca3 100644 --- a/browser/components/loop/ui/ui-showcase.jsx +++ b/browser/components/loop/ui/ui-showcase.jsx @@ -78,7 +78,10 @@ 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 }); mockConversationModel.startSession = noop;