/** @jsx React.DOM */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* global loop:true, React */ var loop = loop || {}; loop.conversationViews = (function(mozL10n) { var CALL_STATES = loop.store.CALL_STATES; var CALL_TYPES = loop.shared.utils.CALL_TYPES; var REST_ERRNOS = loop.shared.utils.REST_ERRNOS; var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS; var sharedActions = loop.shared.actions; var sharedUtils = loop.shared.utils; var sharedViews = loop.shared.views; var sharedMixins = loop.shared.mixins; var sharedModels = loop.shared.models; // 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. // XXXdmose this code is already out of sync with the code in contacts.jsx // which, unlike this code, now has unit tests. We should totally do the // above. 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]; } function _getContactDisplayName(contact) { if (contact.name && contact.name[0]) { return contact.name[0]; } return _getPreferredEmail(contact).value; } /** * 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.props.peerIdentifier}
{this.formatCreationDate()}
); } }); /** * Displays details of the incoming/outgoing conversation * (name, link, audio/video type etc). * * Allows the view to be extended with different buttons and progress * via children properties. */ var ConversationDetailView = React.createClass({ propTypes: { contact: React.PropTypes.object }, render: function() { var contactName = _getContactDisplayName(this.props.contact); return (
{this.props.children}
); } }); // Matches strings of the form "@" or "+" var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/; var AcceptCallView = React.createClass({ mixins: [sharedMixins.DropdownMenuMixin], propTypes: { callType: React.PropTypes.string.isRequired, callerId: React.PropTypes.string.isRequired, dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired, mozLoop: React.PropTypes.object.isRequired, // Only for use by the ui-showcase showMenu: React.PropTypes.bool }, getDefaultProps: function() { return { showMenu: false, }; }, componentDidMount: function() { this.props.mozLoop.startAlerting(); }, componentWillUnmount: function() { this.props.mozLoop.stopAlerting(); }, clickHandler: function(e) { var target = e.target; if (!target.classList.contains('btn-chevron')) { this._hideDeclineMenu(); } }, _handleAccept: function(callType) { return function() { this.props.dispatcher.dispatch(new sharedActions.AcceptCall({ callType: callType })); }.bind(this); }, _handleDecline: function() { this.props.dispatcher.dispatch(new sharedActions.DeclineCall({ blockCaller: false })); }, _handleDeclineBlock: function(e) { this.props.dispatcher.dispatch(new sharedActions.DeclineCall({ blockCaller: true })); /* Prevent event propagation * stop the click from reaching parent element */ return false; }, /* * Generate props for component based on * incoming call type. An incoming video call will render a video * answer button primarily, an audio call will flip them. **/ _answerModeProps: function() { var videoButton = { handler: this._handleAccept(CALL_TYPES.AUDIO_VIDEO), className: "fx-embedded-btn-icon-video", tooltip: "incoming_call_accept_audio_video_tooltip" }; var audioButton = { handler: this._handleAccept(CALL_TYPES.AUDIO_ONLY), className: "fx-embedded-btn-audio-small", tooltip: "incoming_call_accept_audio_only_tooltip" }; var props = {}; props.primary = videoButton; props.secondary = audioButton; // When video is not enabled on this call, we swap the buttons around. if (this.props.callType === CALL_TYPES.AUDIO_ONLY) { audioButton.className = "fx-embedded-btn-icon-audio"; videoButton.className = "fx-embedded-btn-video-small"; props.primary = audioButton; props.secondary = videoButton; } return props; }, render: function() { /* jshint ignore:start */ var dropdownMenuClassesDecline = React.addons.classSet({ "native-dropdown-menu": true, "conversation-window-dropdown": true, "visually-hidden": !this.state.showMenu }); return (
  • {mozL10n.get("incoming_call_cancel_and_block_button")}
); /* jshint ignore:end */ } }); /** * Incoming call view accept button, renders different primary actions * (answer with video / with audio only) based on the props received **/ var AcceptCallButton = React.createClass({ propTypes: { mode: React.PropTypes.object.isRequired, }, render: function() { var mode = this.props.mode; return ( /* jshint ignore:start */
/* jshint ignore:end */ ); } }); /** * Something went wrong view. Displayed when there's a big problem. */ var GenericFailureView = React.createClass({ mixins: [ sharedMixins.AudioMixin, sharedMixins.DocumentTitleMixin ], propTypes: { cancelCall: React.PropTypes.func.isRequired }, componentDidMount: function() { this.play("failure"); }, render: function() { this.setTitle(mozL10n.get("generic_failure_title")); return (

{mozL10n.get("generic_failure_title")}

); } }); /** * View for pending conversations. Displays a cancel button and appropriate * pending/ringing strings. */ var PendingConversationView = React.createClass({ mixins: [sharedMixins.AudioMixin], propTypes: { dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired, callState: React.PropTypes.string, contact: React.PropTypes.object, enableCancelButton: React.PropTypes.bool }, getDefaultProps: function() { return { enableCancelButton: false }; }, componentDidMount: function() { this.play("ringtone", {loop: true}); }, cancelCall: function() { this.props.dispatcher.dispatch(new sharedActions.CancelCall()); }, render: function() { var cx = React.addons.classSet; var pendingStateString; if (this.props.callState === CALL_STATES.ALERTING) { pendingStateString = mozL10n.get("call_progress_ringing_description"); } else { pendingStateString = mozL10n.get("call_progress_connecting_description"); } var btnCancelStyles = cx({ "btn": true, "btn-cancel": true, "disabled": !this.props.enableCancelButton }); return (

{pendingStateString}

); } }); /** * Call failed view. Displayed when a call fails. */ var CallFailedView = React.createClass({ mixins: [ Backbone.Events, loop.store.StoreMixin("conversationStore"), sharedMixins.AudioMixin, sharedMixins.WindowCloseMixin ], propTypes: { dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired, contact: React.PropTypes.object.isRequired, // This is used by the UI showcase. emailLinkError: React.PropTypes.bool, outgoing: React.PropTypes.bool.isRequired }, getInitialState: function() { return { emailLinkError: this.props.emailLinkError, emailLinkButtonDisabled: false }; }, componentDidMount: function() { this.play("failure"); this.listenTo(this.getStore(), "change:emailLink", this._onEmailLinkReceived); this.listenTo(this.getStore(), "error:emailLink", this._onEmailLinkError); }, componentWillUnmount: function() { this.stopListening(this.getStore()); }, _onEmailLinkReceived: function() { var emailLink = this.getStoreState().emailLink; var contactEmail = _getPreferredEmail(this.props.contact).value; sharedUtils.composeCallUrlEmail(emailLink, contactEmail); this.closeWindow(); }, _onEmailLinkError: function() { this.setState({ emailLinkError: true, emailLinkButtonDisabled: false }); }, _renderError: function() { if (!this.state.emailLinkError) { return; } return

{mozL10n.get("unable_retrieve_url")}

; }, _getTitleMessage: function() { var callStateReason = this.getStoreState().callStateReason; if (callStateReason === WEBSOCKET_REASONS.REJECT || callStateReason === WEBSOCKET_REASONS.BUSY || callStateReason === REST_ERRNOS.USER_UNAVAILABLE) { var contactDisplayName = _getContactDisplayName(this.props.contact); if (contactDisplayName.length) { return mozL10n.get( "contact_unavailable_title", {"contactName": contactDisplayName}); } return mozL10n.get("generic_contact_unavailable_title"); } else { return mozL10n.get("generic_failure_title"); } }, retryCall: function() { this.props.dispatcher.dispatch(new sharedActions.RetryCall()); }, cancelCall: function() { this.props.dispatcher.dispatch(new sharedActions.CancelCall()); }, emailLink: function() { this.setState({ emailLinkError: false, emailLinkButtonDisabled: true }); this.props.dispatcher.dispatch(new sharedActions.FetchRoomEmailLink({ roomOwner: navigator.mozLoop.userProfile.email, roomName: _getContactDisplayName(this.props.contact) })); }, _renderMessage: function() { if (this.props.outgoing) { return (

{mozL10n.get("generic_failure_with_reason2")}

); } return null; }, render: function() { var cx = React.addons.classSet; var retryClasses = cx({ btn: true, "btn-info": true, "btn-retry": true, hide: !this.props.outgoing }); var emailClasses = cx({ btn: true, "btn-info": true, "btn-email": true, hide: !this.props.outgoing }); return (

{ this._getTitleMessage() }

{this._renderMessage()} {this._renderError()}
); } }); var OngoingConversationView = React.createClass({ mixins: [ sharedMixins.MediaSetupMixin ], propTypes: { dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired, video: React.PropTypes.object, audio: React.PropTypes.object }, getDefaultProps: function() { return { video: {enabled: true, visible: true}, audio: {enabled: true, visible: true} }; }, componentDidMount: function() { // The SDK needs to know about the configuration and the elements to use // for display. So the best way seems to pass the information here - ideally // the sdk wouldn't need to know this, but we can't change that. this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({ publisherConfig: this.getDefaultPublisherConfig({ publishVideo: this.props.video.enabled }), getLocalElementFunc: this._getElement.bind(this, ".local"), getRemoteElementFunc: this._getElement.bind(this, ".remote") })); }, /** * Hangs up the call. */ hangup: function() { this.props.dispatcher.dispatch( new sharedActions.HangupCall()); }, /** * Used to control publishing a stream - i.e. to mute a stream * * @param {String} type The type of stream, e.g. "audio" or "video". * @param {Boolean} enabled True to enable the stream, false otherwise. */ publishStream: function(type, enabled) { this.props.dispatcher.dispatch( new sharedActions.SetMute({ type: type, enabled: enabled })); }, render: function() { var localStreamClasses = React.addons.classSet({ local: true, "local-stream": true, "local-stream-audio": !this.props.video.enabled }); return (
); } }); /** * Master View Controller for outgoing calls. This manages * the different views that need displaying. */ var CallControllerView = React.createClass({ mixins: [ sharedMixins.AudioMixin, sharedMixins.DocumentTitleMixin, loop.store.StoreMixin("conversationStore"), Backbone.Events ], propTypes: { dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired, mozLoop: React.PropTypes.object.isRequired }, getInitialState: function() { return this.getStoreState(); }, _closeWindow: function() { window.close(); }, /** * Returns true if the call is in a cancellable state, during call setup. */ _isCancellable: function() { return this.state.callState !== CALL_STATES.INIT && this.state.callState !== CALL_STATES.GATHER; }, /** * Used to setup and render the feedback view. */ _renderFeedbackView: function() { this.setTitle(mozL10n.get("conversation_has_ended")); return ( ); }, _renderViewFromCallType: function() { // For outgoing calls we can display the pending conversation view // for any state that render() doesn't manage. if (this.state.outgoing) { return (); } // For incoming calls that are in accepting state, display the // accept call view. if (this.state.callState === CALL_STATES.ALERTING) { return (); } // Otherwise we're still gathering or connecting, so // don't display anything. return null; }, render: function() { // Set the default title to the contact name or the callerId, note // that views may override this, e.g. the feedback view. if (this.state.contact) { this.setTitle(_getContactDisplayName(this.state.contact)); } else { this.setTitle(this.state.callerId || ""); } switch (this.state.callState) { case CALL_STATES.CLOSE: { this._closeWindow(); return null; } case CALL_STATES.TERMINATED: { return (); } case CALL_STATES.ONGOING: { return ( ); } case CALL_STATES.FINISHED: { this.play("terminated"); return this._renderFeedbackView(); } case CALL_STATES.INIT: { // We know what we are, but we haven't got the data yet. return null; } default: { return this._renderViewFromCallType(); } } }, }); return { PendingConversationView: PendingConversationView, CallIdentifierView: CallIdentifierView, ConversationDetailView: ConversationDetailView, CallFailedView: CallFailedView, _getContactDisplayName: _getContactDisplayName, GenericFailureView: GenericFailureView, AcceptCallView: AcceptCallView, OngoingConversationView: OngoingConversationView, CallControllerView: CallControllerView }; })(document.mozL10n || navigator.mozL10n);