Bug 1184933 - Part 1. Implement the refreshed design for the failure view. r=Mardak
authorMarina Rodriguez Iglesias <marina.rodrigueziglesias@telefonica.com>
Thu, 10 Sep 2015 12:01:16 +0100
changeset 294205 f26df0a960522a1c1e50b0b8390b00d93d6b3a67
parent 294204 19081055c2c879def5c6e1ec7620c4b069a3c67b
child 294206 65ad455cd4a04eaa9c5c07dcea0a85cf24685147
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMardak
bugs1184933
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1184933 - Part 1. Implement the refreshed design for the failure view. r=Mardak
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/js/roomStore.js
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/img/sad_hello_icon_64x64.svg
browser/components/loop/content/shared/js/store.js
browser/components/loop/jar.mn
browser/components/loop/modules/MozLoopService.jsm
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
browser/locales/en-US/chrome/browser/loop/loop.properties
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -7,17 +7,17 @@ loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedMixins = loop.shared.mixins;
   var sharedActions = loop.shared.actions;
 
   var CallControllerView = loop.conversationViews.CallControllerView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
   var FeedbackView = loop.feedbackViews.FeedbackView;
-  var GenericFailureView = loop.conversationViews.GenericFailureView;
+  var DirectCallFailureView = loop.conversationViews.DirectCallFailureView;
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
   var AppControllerView = React.createClass({displayName: "AppControllerView",
     mixins: [
       Backbone.Events,
@@ -79,17 +79,20 @@ loop.conversation = (function(mozL10n) {
         case "room": {
           return (React.createElement(DesktopRoomConversationView, {
             dispatcher: this.props.dispatcher, 
             mozLoop: this.props.mozLoop, 
             onCallTerminated: this.handleCallTerminated, 
             roomStore: this.props.roomStore}));
         }
         case "failed": {
-          return React.createElement(GenericFailureView, {cancelCall: this.closeWindow});
+          return (React.createElement(DirectCallFailureView, {
+            contact: {}, 
+            dispatcher: this.props.dispatcher, 
+            outgoing: false}));
         }
         default: {
           // If we don't have a windowType, we don't know what we are yet,
           // so don't display anything.
           return null;
         }
       }
     }
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -7,17 +7,17 @@ loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedMixins = loop.shared.mixins;
   var sharedActions = loop.shared.actions;
 
   var CallControllerView = loop.conversationViews.CallControllerView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
   var FeedbackView = loop.feedbackViews.FeedbackView;
-  var GenericFailureView = loop.conversationViews.GenericFailureView;
+  var DirectCallFailureView = loop.conversationViews.DirectCallFailureView;
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
   var AppControllerView = React.createClass({
     mixins: [
       Backbone.Events,
@@ -79,17 +79,20 @@ loop.conversation = (function(mozL10n) {
         case "room": {
           return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
             mozLoop={this.props.mozLoop}
             onCallTerminated={this.handleCallTerminated}
             roomStore={this.props.roomStore} />);
         }
         case "failed": {
-          return <GenericFailureView cancelCall={this.closeWindow} />;
+          return (<DirectCallFailureView
+            contact={{}}
+            dispatcher={this.props.dispatcher}
+            outgoing={false} />);
         }
         default: {
           // If we don't have a windowType, we don't know what we are yet,
           // so don't display anything.
           return null;
         }
       }
     }
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -308,62 +308,16 @@ loop.conversationViews = (function(mozL1
             )
           )
         )
       );
     }
   });
 
   /**
-   * Something went wrong view. Displayed when there's a big problem.
-   */
-  var GenericFailureView = React.createClass({displayName: "GenericFailureView",
-    mixins: [
-      sharedMixins.AudioMixin,
-      sharedMixins.DocumentTitleMixin
-    ],
-
-    propTypes: {
-      cancelCall: React.PropTypes.func.isRequired,
-      failureReason: React.PropTypes.string
-    },
-
-    componentDidMount: function() {
-      this.play("failure");
-    },
-
-    render: function() {
-      this.setTitle(mozL10n.get("generic_failure_title"));
-
-      var errorString;
-      switch (this.props.failureReason) {
-        case FAILURE_DETAILS.NO_MEDIA:
-        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
-          errorString = mozL10n.get("no_media_failure_message");
-          break;
-        default:
-          errorString = mozL10n.get("generic_failure_title");
-      }
-
-      return (
-        React.createElement("div", {className: "call-window"}, 
-          React.createElement("h2", null, errorString), 
-
-          React.createElement("div", {className: "btn-group call-action-group"}, 
-            React.createElement("button", {className: "btn btn-cancel", 
-                    onClick: this.props.cancelCall}, 
-              mozL10n.get("cancel_button")
-            )
-          )
-        )
-      );
-    }
-  });
-
-  /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({displayName: "PendingConversationView",
     mixins: [sharedMixins.AudioMixin],
 
     propTypes: {
       callState: React.PropTypes.string,
@@ -414,141 +368,180 @@ loop.conversationViews = (function(mozL1
           )
 
         )
       );
     }
   });
 
   /**
-   * Call failed view. Displayed when a call fails.
+   * Used to display errors in direct calls and rooms to the user.
    */
-  var CallFailedView = React.createClass({displayName: "CallFailedView",
+  var FailureInfoView = React.createClass({displayName: "FailureInfoView",
+    propTypes: {
+      contact: React.PropTypes.object,
+      extraFailureMessage: React.PropTypes.string,
+      extraMessage: React.PropTypes.string,
+      failureReason: React.PropTypes.string.isRequired
+    },
+
+    /**
+     * Returns the translated message appropraite to the failure reason.
+     *
+     * @return {String} The translated message for the failure reason.
+     */
+    _getMessage: function() {
+      switch (this.props.failureReason) {
+        case FAILURE_DETAILS.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");
+        case FAILURE_DETAILS.NO_MEDIA:
+        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
+          return mozL10n.get("no_media_failure_message");
+        default:
+          return mozL10n.get("generic_failure_message");
+      }
+    },
+
+    _renderExtraMessage: function() {
+      if (this.props.extraMessage) {
+        return React.createElement("p", {className: "failure-info-extra"}, this.props.extraMessage);
+      }
+      return null;
+    },
+
+    _renderExtraFailureMessage: function() {
+      if (this.props.extraFailureMessage) {
+        return React.createElement("p", {className: "failure-info-extra-failure"}, this.props.extraFailureMessage);
+      }
+      return null;
+    },
+
+    render: function() {
+      return (
+        React.createElement("div", {className: "failure-info"}, 
+          React.createElement("div", {className: "failure-info-logo"}), 
+          React.createElement("h2", {className: "failure-info-message"}, this._getMessage()), 
+          this._renderExtraMessage(), 
+          this._renderExtraFailureMessage()
+        )
+      );
+    }
+  });
+
+  /**
+   * Direct Call failure view. Displayed when a call fails.
+   */
+  var DirectCallFailureView = React.createClass({displayName: "DirectCallFailureView",
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationStore"),
       sharedMixins.AudioMixin,
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
-      contact: React.PropTypes.object.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // This is used by the UI showcase.
       emailLinkError: React.PropTypes.bool,
       outgoing: React.PropTypes.bool.isRequired
     },
 
     getInitialState: function() {
-      return {
+      return _.extend({
         emailLinkError: this.props.emailLinkError,
         emailLinkButtonDisabled: false
-      };
+      }, this.getStoreState());
     },
 
     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;
+      var contactEmail = _getPreferredEmail(this.state.contact).value;
       sharedUtils.composeCallUrlEmail(emailLink, contactEmail, null, "callfailed");
       this.closeWindow();
     },
 
     _onEmailLinkError: function() {
       this.setState({
         emailLinkError: true,
         emailLinkButtonDisabled: false
       });
     },
 
-    _renderError: function() {
-      if (!this.state.emailLinkError) {
-        return;
-      }
-      return React.createElement("p", {className: "error"}, mozL10n.get("unable_retrieve_url"));
-    },
-
-    _getTitleMessage: function() {
-      switch (this.getStoreState().callStateReason) {
-        case FAILURE_DETAILS.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");
-        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
-          return mozL10n.get("no_media_failure_message");
-        default:
-          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({
-        roomName: _getContactDisplayName(this.props.contact)
+        roomName: _getContactDisplayName(this.state.contact)
       }));
     },
 
-    _renderMessage: function() {
-      if (this.props.outgoing) {
-        return  (React.createElement("p", {className: "btn-label"}, 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
       });
 
+      var extraMessage;
+
+      if (this.props.outgoing) {
+        extraMessage = mozL10n.get("generic_failure_with_reason2");
+      }
+
+      var extraFailureMessage;
+
+      if (this.state.emailLinkError) {
+        extraFailureMessage = mozL10n.get("unable_retrieve_url");
+      }
+
       return (
-        React.createElement("div", {className: "call-window"}, 
-          React.createElement("h2", null,  this._getTitleMessage() ), 
-
-          this._renderMessage(), 
-          this._renderError(), 
+        React.createElement("div", {className: "direct-call-failure"}, 
+          React.createElement(FailureInfoView, {
+            contact: this.state.contact, 
+            extraFailureMessage: extraFailureMessage, 
+            extraMessage: extraMessage, 
+            failureReason: this.getStoreState().callStateReason}), 
 
           React.createElement("div", {className: "btn-group call-action-group"}, 
             React.createElement("button", {className: "btn btn-cancel", 
                     onClick: this.cancelCall}, 
               mozL10n.get("cancel_button")
             ), 
             React.createElement("button", {className: retryClasses, 
                     onClick: this.retryCall}, 
@@ -802,18 +795,17 @@ loop.conversationViews = (function(mozL1
       }
 
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
-          return (React.createElement(CallFailedView, {
-            contact: this.state.contact, 
+          return (React.createElement(DirectCallFailureView, {
             dispatcher: this.props.dispatcher, 
             outgoing: this.state.outgoing}));
         }
         case CALL_STATES.ONGOING: {
           return (React.createElement(OngoingConversationView, {
             audio: { enabled: !this.state.audioMuted, visible: true}, 
             conversationStore: this.getStore(), 
             dispatcher: this.props.dispatcher, 
@@ -841,17 +833,17 @@ loop.conversationViews = (function(mozL1
       }
     }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
-    CallFailedView: CallFailedView,
     _getContactDisplayName: _getContactDisplayName,
-    GenericFailureView: GenericFailureView,
+    FailureInfoView: FailureInfoView,
+    DirectCallFailureView: DirectCallFailureView,
     AcceptCallView: AcceptCallView,
     OngoingConversationView: OngoingConversationView,
     CallControllerView: CallControllerView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -308,62 +308,16 @@ loop.conversationViews = (function(mozL1
             </div>
           </div>
         </div>
       );
     }
   });
 
   /**
-   * 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,
-      failureReason: React.PropTypes.string
-    },
-
-    componentDidMount: function() {
-      this.play("failure");
-    },
-
-    render: function() {
-      this.setTitle(mozL10n.get("generic_failure_title"));
-
-      var errorString;
-      switch (this.props.failureReason) {
-        case FAILURE_DETAILS.NO_MEDIA:
-        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
-          errorString = mozL10n.get("no_media_failure_message");
-          break;
-        default:
-          errorString = mozL10n.get("generic_failure_title");
-      }
-
-      return (
-        <div className="call-window">
-          <h2>{errorString}</h2>
-
-          <div className="btn-group call-action-group">
-            <button className="btn btn-cancel"
-                    onClick={this.props.cancelCall}>
-              {mozL10n.get("cancel_button")}
-            </button>
-          </div>
-        </div>
-      );
-    }
-  });
-
-  /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({
     mixins: [sharedMixins.AudioMixin],
 
     propTypes: {
       callState: React.PropTypes.string,
@@ -414,141 +368,180 @@ loop.conversationViews = (function(mozL1
           </div>
 
         </ConversationDetailView>
       );
     }
   });
 
   /**
-   * Call failed view. Displayed when a call fails.
+   * Used to display errors in direct calls and rooms to the user.
    */
-  var CallFailedView = React.createClass({
+  var FailureInfoView = React.createClass({
+    propTypes: {
+      contact: React.PropTypes.object,
+      extraFailureMessage: React.PropTypes.string,
+      extraMessage: React.PropTypes.string,
+      failureReason: React.PropTypes.string.isRequired
+    },
+
+    /**
+     * Returns the translated message appropraite to the failure reason.
+     *
+     * @return {String} The translated message for the failure reason.
+     */
+    _getMessage: function() {
+      switch (this.props.failureReason) {
+        case FAILURE_DETAILS.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");
+        case FAILURE_DETAILS.NO_MEDIA:
+        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
+          return mozL10n.get("no_media_failure_message");
+        default:
+          return mozL10n.get("generic_failure_message");
+      }
+    },
+
+    _renderExtraMessage: function() {
+      if (this.props.extraMessage) {
+        return <p className="failure-info-extra">{this.props.extraMessage}</p>;
+      }
+      return null;
+    },
+
+    _renderExtraFailureMessage: function() {
+      if (this.props.extraFailureMessage) {
+        return <p className="failure-info-extra-failure">{this.props.extraFailureMessage}</p>;
+      }
+      return null;
+    },
+
+    render: function() {
+      return (
+        <div className="failure-info">
+          <div className="failure-info-logo" />
+          <h2 className="failure-info-message">{this._getMessage()}</h2>
+          {this._renderExtraMessage()}
+          {this._renderExtraFailureMessage()}
+        </div>
+      );
+    }
+  });
+
+  /**
+   * Direct Call failure view. Displayed when a call fails.
+   */
+  var DirectCallFailureView = React.createClass({
     mixins: [
       Backbone.Events,
       loop.store.StoreMixin("conversationStore"),
       sharedMixins.AudioMixin,
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
-      contact: React.PropTypes.object.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // This is used by the UI showcase.
       emailLinkError: React.PropTypes.bool,
       outgoing: React.PropTypes.bool.isRequired
     },
 
     getInitialState: function() {
-      return {
+      return _.extend({
         emailLinkError: this.props.emailLinkError,
         emailLinkButtonDisabled: false
-      };
+      }, this.getStoreState());
     },
 
     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;
+      var contactEmail = _getPreferredEmail(this.state.contact).value;
       sharedUtils.composeCallUrlEmail(emailLink, contactEmail, null, "callfailed");
       this.closeWindow();
     },
 
     _onEmailLinkError: function() {
       this.setState({
         emailLinkError: true,
         emailLinkButtonDisabled: false
       });
     },
 
-    _renderError: function() {
-      if (!this.state.emailLinkError) {
-        return;
-      }
-      return <p className="error">{mozL10n.get("unable_retrieve_url")}</p>;
-    },
-
-    _getTitleMessage: function() {
-      switch (this.getStoreState().callStateReason) {
-        case FAILURE_DETAILS.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");
-        case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
-          return mozL10n.get("no_media_failure_message");
-        default:
-          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({
-        roomName: _getContactDisplayName(this.props.contact)
+        roomName: _getContactDisplayName(this.state.contact)
       }));
     },
 
-    _renderMessage: function() {
-      if (this.props.outgoing) {
-        return  (<p className="btn-label">{mozL10n.get("generic_failure_with_reason2")}</p>);
-      }
-
-      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
       });
 
+      var extraMessage;
+
+      if (this.props.outgoing) {
+        extraMessage = mozL10n.get("generic_failure_with_reason2");
+      }
+
+      var extraFailureMessage;
+
+      if (this.state.emailLinkError) {
+        extraFailureMessage = mozL10n.get("unable_retrieve_url");
+      }
+
       return (
-        <div className="call-window">
-          <h2>{ this._getTitleMessage() }</h2>
-
-          {this._renderMessage()}
-          {this._renderError()}
+        <div className="direct-call-failure">
+          <FailureInfoView
+            contact={this.state.contact}
+            extraFailureMessage={extraFailureMessage}
+            extraMessage={extraMessage}
+            failureReason={this.getStoreState().callStateReason}/>
 
           <div className="btn-group call-action-group">
             <button className="btn btn-cancel"
                     onClick={this.cancelCall}>
               {mozL10n.get("cancel_button")}
             </button>
             <button className={retryClasses}
                     onClick={this.retryCall}>
@@ -802,18 +795,17 @@ loop.conversationViews = (function(mozL1
       }
 
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
-          return (<CallFailedView
-            contact={this.state.contact}
+          return (<DirectCallFailureView
             dispatcher={this.props.dispatcher}
             outgoing={this.state.outgoing} />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             audio={{ enabled: !this.state.audioMuted, visible: true }}
             conversationStore={this.getStore()}
             dispatcher={this.props.dispatcher}
@@ -841,17 +833,17 @@ loop.conversationViews = (function(mozL1
       }
     }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
-    CallFailedView: CallFailedView,
     _getContactDisplayName: _getContactDisplayName,
-    GenericFailureView: GenericFailureView,
+    FailureInfoView: FailureInfoView,
+    DirectCallFailureView: DirectCallFailureView,
     AcceptCallView: AcceptCallView,
     OngoingConversationView: OngoingConversationView,
     CallControllerView: CallControllerView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/roomStore.js
+++ b/browser/components/loop/content/js/roomStore.js
@@ -319,17 +319,17 @@ loop.store = loop.store || {};
         error: actionData.error,
         pendingCreation: false
       });
 
       // XXX Needs a more descriptive error - bug 1109151.
       this._notifications.set({
         id: "create-room-error",
         level: "error",
-        message: mozL10n.get("generic_failure_title")
+        message: mozL10n.get("generic_failure_message")
       });
     },
 
     /**
      * Copy a room url.
      *
      * @param  {sharedActions.CopyRoomUrl} actionData The action data.
      */
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -69,16 +69,51 @@ loop.roomViews = (function(mozL10n) {
       return _.extend({
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState,
         savingContext: false
       }, storeState);
     }
   };
 
+  /**
+   * Something went wrong view. Displayed when there's a big problem.
+   */
+  var RoomFailureView = React.createClass({displayName: "RoomFailureView",
+    mixins: [ sharedMixins.AudioMixin ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      failureReason: React.PropTypes.string
+    },
+
+    componentDidMount: function() {
+      this.play("failure");
+    },
+
+    handleRejoinCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    render: function() {
+      return (
+        React.createElement("div", {className: "room-failure"}, 
+          React.createElement(loop.conversationViews.FailureInfoView, {
+            failureReason: this.props.failureReason}), 
+          React.createElement("div", {className: "btn-group call-action-group"}, 
+            React.createElement("button", {className: "btn btn-info btn-rejoin", 
+                    onClick: this.handleRejoinCall}, 
+              mozL10n.get("rejoin_button")
+            )
+          )
+        )
+      );
+    }
+  });
+
   var SocialShareDropdown = React.createClass({displayName: "SocialShareDropdown",
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       roomUrl: React.PropTypes.string,
       show: React.PropTypes.bool.isRequired,
       socialShareProviders: React.PropTypes.array
     },
 
@@ -721,19 +756,20 @@ loop.roomViews = (function(mozL10n) {
       var roomData = this.props.roomStore.getStoreState("activeRoom");
 
       switch(this.state.roomState) {
         case ROOM_STATES.FAILED:
         case ROOM_STATES.FULL: {
           // Note: While rooms are set to hold a maximum of 2 participants, the
           //       FULL case should never happen on desktop.
           return (
-            React.createElement(loop.conversationViews.GenericFailureView, {
-              cancelCall: this.closeWindow, 
-              failureReason: this.state.failureReason})
+            React.createElement(RoomFailureView, {
+              dispatcher: this.props.dispatcher, 
+              failureReason: this.state.failureReason, 
+              mozLoop: this.props.mozLoop})
           );
         }
         case ROOM_STATES.ENDED: {
           // When conversation ended we either display a feedback form or
           // close the window. This is decided in the AppControllerView.
           return null;
         }
         default: {
@@ -799,15 +835,16 @@ loop.roomViews = (function(mozL10n) {
           );
         }
       }
     }
   });
 
   return {
     ActiveRoomStoreMixin: ActiveRoomStoreMixin,
+    RoomFailureView: RoomFailureView,
     SocialShareDropdown: SocialShareDropdown,
     DesktopRoomEditContextView: DesktopRoomEditContextView,
     DesktopRoomConversationView: DesktopRoomConversationView,
     DesktopRoomInvitationView: DesktopRoomInvitationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -69,16 +69,51 @@ loop.roomViews = (function(mozL10n) {
       return _.extend({
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState,
         savingContext: false
       }, storeState);
     }
   };
 
+  /**
+   * Something went wrong view. Displayed when there's a big problem.
+   */
+  var RoomFailureView = React.createClass({
+    mixins: [ sharedMixins.AudioMixin ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      failureReason: React.PropTypes.string
+    },
+
+    componentDidMount: function() {
+      this.play("failure");
+    },
+
+    handleRejoinCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    render: function() {
+      return (
+        <div className="room-failure">
+          <loop.conversationViews.FailureInfoView
+            failureReason={this.props.failureReason} />
+          <div className="btn-group call-action-group">
+            <button className="btn btn-info btn-rejoin"
+                    onClick={this.handleRejoinCall}>
+              {mozL10n.get("rejoin_button")}
+            </button>
+          </div>
+        </div>
+      );
+    }
+  });
+
   var SocialShareDropdown = React.createClass({
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       roomUrl: React.PropTypes.string,
       show: React.PropTypes.bool.isRequired,
       socialShareProviders: React.PropTypes.array
     },
 
@@ -721,19 +756,20 @@ loop.roomViews = (function(mozL10n) {
       var roomData = this.props.roomStore.getStoreState("activeRoom");
 
       switch(this.state.roomState) {
         case ROOM_STATES.FAILED:
         case ROOM_STATES.FULL: {
           // Note: While rooms are set to hold a maximum of 2 participants, the
           //       FULL case should never happen on desktop.
           return (
-            <loop.conversationViews.GenericFailureView
-              cancelCall={this.closeWindow}
-              failureReason={this.state.failureReason} />
+            <RoomFailureView
+              dispatcher={this.props.dispatcher}
+              failureReason={this.state.failureReason}
+              mozLoop={this.props.mozLoop} />
           );
         }
         case ROOM_STATES.ENDED: {
           // When conversation ended we either display a feedback form or
           // close the window. This is decided in the AppControllerView.
           return null;
         }
         default: {
@@ -799,15 +835,16 @@ loop.roomViews = (function(mozL10n) {
           );
         }
       }
     }
   });
 
   return {
     ActiveRoomStoreMixin: ActiveRoomStoreMixin,
+    RoomFailureView: RoomFailureView,
     SocialShareDropdown: SocialShareDropdown,
     DesktopRoomEditContextView: DesktopRoomEditContextView,
     DesktopRoomConversationView: DesktopRoomConversationView,
     DesktopRoomInvitationView: DesktopRoomInvitationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -279,55 +279,109 @@ html[dir="rtl"] .conversation-toolbar-bt
 .call-window {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: space-between;
   min-height: 230px;
 }
 
-.call-window > .btn-label {
-  text-align: center;
-}
-
-.call-window > .error {
-  text-align: center;
-  color: #f00;
-  font-size: 90%;
-}
-
 .call-action-group {
   display: flex;
-  padding: 2.5em 4px 0 4px;
+  padding: 0 4px;
   width: 100%;
 }
 
 .call-action-group > .btn,
 .room-context > .btn {
-  min-height: 26px;
-  border-radius: 2px;
+  min-height: 30px;
+  border-radius: 4px;
   margin: 0 4px;
   min-width: 64px;
 }
 
+.call-action-group > .btn {
+  max-width: 48%;
+  flex-grow: 1;
+}
+
+.call-action-group > .btn-rejoin {
+  max-width: 100%;
+}
+
 .call-action-group .btn-group-chevron,
 .call-action-group .btn-group {
   width: 100%;
 }
 
-/* XXX Once we get the incoming call avatar, bug 1047435, the H2 should
- * disappear from our markup, and we should remove this rule entirely.
- */
-.call-window h2 {
-  font-size: 1.5em;
-  font-weight: normal;
+.direct-call-failure,
+.room-failure {
+  /* This flex allows us to not calculate the height of the logo area
+     versus the buttons */
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 230px;
+  height: 100%;
+}
+
+.direct-call-failure > .call-action-group,
+.room-failure > .call-action-group {
+  flex: none;
+  margin: 1rem 0 2rem;
+}
+
+.direct-call-failure > .failure-info,
+.room-failure > .failure-info {
+  flex: auto;
+}
+
+.failure-info {
   text-align: center;
-  /* compensate for reset.css overriding this; values borrowed from
-     Firefox Mac html.css */
-  margin: 0.83em 0;
+  /* This flex is designed to set the logo in a standard place, but if the
+     text below needs more space (due to multi-line), then the logo will move
+     higher out the way */
+  display: flex;
+  flex-direction: column;
+  /* Matches 4px padding of .btn-group plus 4px of margin for .btn */
+  padding: 0 0.8rem;
+}
+
+.failure-info-logo {
+  height: 90px;
+  background-image: url("../img/sad_hello_icon_64x64.svg");
+  background-position: center center;
+  background-size: contain;
+  background-repeat: no-repeat;
+  flex: 1;
+  background-size: 90px 90px;
+  /* Don't let the logo take up too much space, e.g. if there's only one line of
+     text. */
+  max-height: calc(90px + 4rem);
+  margin-top: 1rem;
+}
+
+.failure-info-message {
+  margin: 0.25rem 0px;
+  text-align: center;
+  font-weight: bold;
+  font-size: 1.2rem;
+  color: #333;
+  flex: none;
+}
+
+.failure-info-extra,
+.failure-info-extra-failure {
+  margin: 0.25rem 0;
+  flex: none;
+}
+
+.failure-info-extra-failure {
+  color: #f00;
 }
 
 .fx-embedded-call-button-spacer {
   display: flex;
   flex: 1;
 }
 
 /*
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/sad_hello_icon_64x64.svg
@@ -0,0 +1,1 @@
+<svg width="180" height="173" viewBox="0 0 180 173" xmlns="http://www.w3.org/2000/svg"><title>Firefox-Hello_icon_64x64 7 + Oval 49 + Bitmap 62</title><path d="M89.994 0C40.304 0 0 35.475 0 79.218c0 21.78 9.993 41.513 26.146 55.832-2.806 9.91-8.362 23.362-19.34 36.492 1.88 3.327 32.794-8.41 54.588-17.188 8.993 2.64 18.597 4.084 28.6 4.084 49.706 0 90.006-35.47 90.006-79.22C180 35.475 139.7 0 89.994 0zm26.922 47.5c6.566 0 11.894 5.343 11.894 11.923 0 6.598-5.328 11.93-11.894 11.93-6.576 0-11.907-5.332-11.907-11.93 0-6.58 5.33-11.923 11.906-11.923zm-54.3 0c6.564 0 11.903 5.343 11.903 11.923 0 6.598-5.34 11.93-11.905 11.93-6.578 0-11.908-5.332-11.908-11.93 0-6.58 5.33-11.923 11.908-11.923zm60.346 42.758l-64.72 24.27c-2.09.784-3.15 3.115-2.366 5.207.784 2.092 3.115 3.15 5.207 2.367l64.72-24.27c2.09-.784 3.15-3.116 2.366-5.207-.785-2.092-3.117-3.152-5.208-2.367z" fill="#9B9B9B" fill-rule="evenodd"/></svg>
\ No newline at end of file
--- a/browser/components/loop/content/shared/js/store.js
+++ b/browser/components/loop/content/shared/js/store.js
@@ -112,25 +112,26 @@ loop.store.createStore = (function() {
  *       mixins: [StoreMixin("roomStore")]
  *     });
  */
 loop.store.StoreMixin = (function() {
   "use strict";
 
   var _stores = {};
   function StoreMixin(id) {
-    function _getStore() {
-      if (!_stores[id]) {
-        throw new Error("Unavailable store " + id);
-      }
-      return _stores[id];
-    }
     return {
       getStore: function() {
-        return _getStore();
+        // Allows the ui-showcase to override the specified store.
+        if (id in this.props) {
+          return this.props[id];
+        }
+        if (!_stores[id]) {
+          throw new Error("Unavailable store " + id);
+        }
+        return _stores[id];
       },
       getStoreState: function() {
         return this.getStore().getStoreState();
       },
       componentWillMount: function() {
         this.getStore().on("change", function() {
           this.setState(this.getStoreState());
         }, this);
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -35,16 +35,17 @@ browser.jar:
   content/browser/loop/shared/img/helloicon.svg                 (content/shared/img/helloicon.svg)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
   content/browser/loop/shared/img/spinner.svg                   (content/shared/img/spinner.svg)
   # XXX could get rid of the png spinner usages and replace them with the svg
   # one?
   content/browser/loop/shared/img/spinner.png                   (content/shared/img/spinner.png)
   content/browser/loop/shared/img/spinner@2x.png                (content/shared/img/spinner@2x.png)
+  content/browser/loop/shared/img/sad_hello_icon_64x64.svg      (content/shared/img/sad_hello_icon_64x64.svg)
   content/browser/loop/shared/img/chatbubble-arrow-left.svg     (content/shared/img/chatbubble-arrow-left.svg)
   content/browser/loop/shared/img/chatbubble-arrow-right.svg    (content/shared/img/chatbubble-arrow-right.svg)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
   content/browser/loop/shared/img/audio-inverse-14x14@2x.png    (content/shared/img/audio-inverse-14x14@2x.png)
   content/browser/loop/shared/img/facemute-14x14.png            (content/shared/img/facemute-14x14.png)
   content/browser/loop/shared/img/facemute-14x14@2x.png         (content/shared/img/facemute-14x14@2x.png)
   content/browser/loop/shared/img/hangup-inverse-14x14.png      (content/shared/img/hangup-inverse-14x14.png)
   content/browser/loop/shared/img/hangup-inverse-14x14@2x.png   (content/shared/img/hangup-inverse-14x14@2x.png)
--- a/browser/components/loop/modules/MozLoopService.jsm
+++ b/browser/components/loop/modules/MozLoopService.jsm
@@ -340,17 +340,17 @@ let MozLoopServiceInternal = {
       } else {
         messageString = "session_expired_error_description";
       }
     } else if (error.code >= 500 && error.code < 600) {
       messageString = "service_not_available";
       detailsString = "try_again_later";
       detailsButtonLabelString = "retry_button";
     } else {
-      messageString = "generic_failure_title";
+      messageString = "generic_failure_message";
     }
 
     error.friendlyMessage = this.localizedStrings.get(messageString);
 
     // Default to the generic "retry_button" text even though the button won't be shown if
     // error.friendlyDetails is null.
     error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
                                          this.localizedStrings.get(detailsButtonLabelString) :
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -1,17 +1,17 @@
 ## LOCALIZATION NOTE: In this file, don't translate the part between {{..}}
 restart_call=Rejoin
 conversation_has_ended=Your conversation has ended.
 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.
-generic_failure_title=Something went wrong.
+generic_failure_message=We're having technical difficulties…
 generic_failure_with_reason2=You can try again or email a link to be reached at later.
 generic_failure_no_reason2=Would you like to try again?
 retry_call_button=Retry
 unable_retrieve_call_info=Unable to retrieve conversation information.
 hangup_button_title=Hang up
 hangup_button_caption2=Exit
 mute_local_audio_button_title=Mute your audio
 unmute_local_audio_button_title=Unmute your audio
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -255,206 +255,267 @@ describe("loop.conversationViews", funct
         React.addons.TestUtils.Simulate.click(cancelBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "cancelCall"));
       });
   });
 
-  describe("CallFailedView", function() {
+  describe("FailureInfoView", function() {
+    function mountTestComponent(options) {
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.conversationViews.FailureInfoView, options));
+    }
+
+    it("should display a generic failure message by default", function() {
+      view = mountTestComponent({
+        failureReason: "fake"
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("generic_failure_message");
+    });
+
+    it("should display a no media message for the no media reason", function() {
+      view = mountTestComponent({
+        failureReason: FAILURE_DETAILS.NO_MEDIA
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("no_media_failure_message");
+    });
+
+    it("should display a no media message for the unable to publish reason", function() {
+      view = mountTestComponent({
+        failureReason: FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("no_media_failure_message");
+    });
+
+    it("should display a user unavailable message for the unavailable reason", function() {
+      view = mountTestComponent({
+        contact: {email: [{value: "test@test.tld"}]},
+        failureReason: FAILURE_DETAILS.USER_UNAVAILABLE
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("contact_unavailable_title");
+    });
+
+    it("should display a generic unavailable message if the contact doesn't have a display name", function() {
+      view = mountTestComponent({
+        contact: {
+          tel: [{"pref": true, type: "work", value: ""}]
+        },
+        failureReason: FAILURE_DETAILS.USER_UNAVAILABLE
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("generic_contact_unavailable_title");
+    });
+
+    it("should display an extra message", function() {
+      view = mountTestComponent({
+        extraMessage: "Fake message",
+        failureReason: FAILURE_DETAILS.UNKNOWN
+      });
+
+      var extraMessage = view.getDOMNode().querySelector(".failure-info-extra");
+
+      expect(extraMessage.textContent).eql("Fake message");
+    });
+
+    it("should display an extra failure message", function() {
+      view = mountTestComponent({
+        extraFailureMessage: "Fake failure message",
+        failureReason: FAILURE_DETAILS.UNKNOWN
+      });
+
+      var extraFailureMessage = view.getDOMNode().querySelector(".failure-info-extra-failure");
+
+      expect(extraFailureMessage.textContent).eql("Fake failure message");
+    });
+  });
+
+  describe("DirectCallFailureView", function() {
     var fakeAudio, composeCallUrlEmail;
 
     var fakeContact = {email: [{value: "test@test.tld"}]};
 
     function mountTestComponent(options) {
-      options = options || {};
-      return TestUtils.renderIntoDocument(
-        React.createElement(loop.conversationViews.CallFailedView, {
+      var props = _.extend({
           dispatcher: dispatcher,
-          contact: options.contact,
           outgoing: true
-        }));
+        }, options);
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.conversationViews.DirectCallFailureView, props));
     }
 
     beforeEach(function() {
       fakeAudio = {
         play: sinon.spy(),
         pause: sinon.spy(),
         removeAttribute: sinon.spy()
       };
       sandbox.stub(window, "Audio").returns(fakeAudio);
       composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
+      conversationStore.setStoreState({
+        callStateReason: FAILURE_DETAILS.UNKNOWN,
+        contact: fakeContact
+      });
+    });
+
+    it("should not display the retry button for incoming calls", function() {
+      view = mountTestComponent({outgoing: false});
+
+      var retryBtn = view.getDOMNode().querySelector(".btn-retry");
+
+      expect(retryBtn.classList.contains("hide")).eql(true);
+    });
+
+    it("should not display the email button for incoming calls", function() {
+      view = mountTestComponent({outgoing: false});
+
+      var retryBtn = view.getDOMNode().querySelector(".btn-email");
+
+      expect(retryBtn.classList.contains("hide")).eql(true);
     });
 
     it("should dispatch a retryCall action when the retry button is pressed",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        view = mountTestComponent();
 
         var retryBtn = view.getDOMNode().querySelector(".btn-retry");
 
         React.addons.TestUtils.Simulate.click(retryBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "retryCall"));
       });
 
     it("should dispatch a cancelCall action when the cancel button is pressed",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        view = mountTestComponent();
 
         var cancelBtn = view.getDOMNode().querySelector(".btn-cancel");
 
         React.addons.TestUtils.Simulate.click(cancelBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "cancelCall"));
       });
 
     it("should dispatch a fetchRoomEmailLink action when the email button is pressed",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        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", "fetchRoomEmailLink"));
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("roomName", "test@test.tld"));
       });
 
     it("should name the created room using the contact name when available",
       function() {
-        view = mountTestComponent({contact: {
-          email: [{value: "test@test.tld"}],
-          name: ["Mr Fake ContactName"]
-        }});
+        conversationStore.setStoreState({
+          contact: {
+            email: [{value: "test@test.tld"}],
+            name: ["Mr Fake ContactName"]
+          }
+        });
+
+        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("roomName", "Mr Fake ContactName"));
       });
 
     it("should disable the email link button once the action is dispatched",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        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() {
-      view = mountTestComponent({contact: fakeContact});
+      view = mountTestComponent();
       conversationStore.setStoreState({emailLink: "http://fake.invalid/"});
 
       sinon.assert.calledOnce(composeCallUrlEmail);
       sinon.assert.calledWithExactly(composeCallUrlEmail,
         "http://fake.invalid/", "test@test.tld", null, "callfailed");
     });
 
     it("should close the conversation window once the email link is received",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        view = mountTestComponent();
 
         conversationStore.setStoreState({emailLink: "http://fake.invalid/"});
 
         sinon.assert.calledOnce(fakeWindow.close);
       });
 
     it("should display an error message in case email link retrieval failed",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        view = mountTestComponent();
 
         conversationStore.trigger("error:emailLink");
 
-        expect(view.getDOMNode().querySelector(".error")).not.eql(null);
+        expect(view.getDOMNode().querySelector(".failure-info-extra-failure")).not.eql(null);
       });
 
     it("should allow retrying to get a call url if it failed previously",
       function() {
-        view = mountTestComponent({contact: fakeContact});
+        view = mountTestComponent();
 
         conversationStore.trigger("error:emailLink");
 
         expect(view.getDOMNode().querySelector(".btn-email").disabled).eql(false);
       });
 
     it("should play a failure sound, once", function() {
-      view = mountTestComponent({contact: fakeContact});
+      view = mountTestComponent();
 
       sinon.assert.calledOnce(navigator.mozLoop.getAudioBlob);
       sinon.assert.calledWithExactly(navigator.mozLoop.getAudioBlob,
                                      "failure", sinon.match.func);
       sinon.assert.calledOnce(fakeAudio.play);
       expect(fakeAudio.loop).to.equal(false);
     });
 
-    it("should show 'something went wrong' when the reason is WEBSOCKET_REASONS.MEDIA_FAIL",
-      function () {
-        conversationStore.setStoreState({callStateReason: WEBSOCKET_REASONS.MEDIA_FAIL});
-
-        view = mountTestComponent({contact: fakeContact});
-
-        sinon.assert.calledWith(document.mozL10n.get, "generic_failure_title");
-      });
-
-    it("should show 'something went wrong' when the reason is 'setup'",
-      function () {
-        conversationStore.setStoreState({callStateReason: "setup"});
-
-        view = mountTestComponent({contact: fakeContact});
-
-        sinon.assert.calledWithExactly(document.mozL10n.get,
-          "generic_failure_title");
+    it("should display an additional message for outgoing calls", function() {
+      view = mountTestComponent({
+        outgoing: true
       });
 
-    it("should show 'contact unavailable' when the reason is FAILURE_DETAILS.USER_UNAVAILABLE",
-      function () {
-        conversationStore.setStoreState({callStateReason: FAILURE_DETAILS.USER_UNAVAILABLE});
-
-        view = mountTestComponent({contact: fakeContact});
-
-        sinon.assert.calledWithExactly(document.mozL10n.get,
-          "contact_unavailable_title",
-          {contactName: loop.conversationViews
-                            ._getContactDisplayName(fakeContact)});
-      });
-
-    it("should show 'no media' when the reason is FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA",
-      function () {
-        conversationStore.setStoreState({callStateReason: FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA});
+      var extraMessage = view.getDOMNode().querySelector(".failure-info-extra");
 
-        view = mountTestComponent({contact: fakeContact});
-
-        sinon.assert.calledWithExactly(document.mozL10n.get, "no_media_failure_message");
-      });
-
-    it("should display a generic contact unavailable msg when the reason is" +
-       " FAILURE_DETAILS.USER_UNAVAILABLE and no display name is available", function() {
-        conversationStore.setStoreState({
-          callStateReason: FAILURE_DETAILS.USER_UNAVAILABLE
-        });
-        var phoneOnlyContact = {
-          tel: [{"pref": true, type: "work", value: ""}]
-        };
-
-        view = mountTestComponent({contact: phoneOnlyContact});
-
-        sinon.assert.calledWith(document.mozL10n.get,
-          "generic_contact_unavailable_title");
+      expect(extraMessage.textContent).eql("generic_failure_with_reason2");
     });
   });
 
   describe("OngoingConversationView", function() {
     function mountTestComponent(extraProps) {
       var props = _.extend({
         conversationStore: conversationStore,
         dispatcher: dispatcher,
@@ -616,28 +677,29 @@ describe("loop.conversationViews", funct
         callerId: "fakeId"
       });
 
       mountTestComponent({contact: contact});
 
       expect(fakeWindow.document.title).eql("fakeId");
     });
 
-    it("should render the CallFailedView when the call state is 'terminated'",
+    it("should render the DirectCallFailureView when the call state is 'terminated'",
       function() {
         conversationStore.setStoreState({
           callState: CALL_STATES.TERMINATED,
           contact: contact,
+          callStateReason: WEBSOCKET_REASONS.CLOSED,
           outgoing: true
         });
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
-          loop.conversationViews.CallFailedView);
+          loop.conversationViews.DirectCallFailureView);
     });
 
     it("should render the PendingConversationView for outgoing calls when the call state is 'gather'",
       function() {
         conversationStore.setStoreState({
           callState: CALL_STATES.GATHER,
           contact: contact,
           outgoing: true
@@ -690,26 +752,25 @@ describe("loop.conversationViews", funct
 
     it("should update the rendered views when the state is changed.",
       function() {
         conversationStore.setStoreState({
           callState: CALL_STATES.GATHER,
           contact: contact,
           outgoing: true
         });
-
         view = mountTestComponent();
-
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
-
-        conversationStore.setStoreState({callState: CALL_STATES.TERMINATED});
-
+        conversationStore.setStoreState({
+          callState: CALL_STATES.TERMINATED,
+          callStateReason: WEBSOCKET_REASONS.CLOSED
+        });
         TestUtils.findRenderedComponentWithType(view,
-          loop.conversationViews.CallFailedView);
+          loop.conversationViews.DirectCallFailureView);
     });
 
     it("should call onCallTerminated when the call is finished", function() {
       conversationStore.setStoreState({
         callState: CALL_STATES.ONGOING
       });
 
       view = mountTestComponent({
@@ -885,73 +946,9 @@ describe("loop.conversationViews", funct
         TestUtils.Simulate.click(buttonBlock);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.DeclineCall({blockCaller: true}));
       });
     });
   });
-
-  describe("GenericFailureView", function() {
-    var callView, fakeAudio;
-
-    function mountTestComponent(props) {
-      return TestUtils.renderIntoDocument(
-        React.createElement(loop.conversationViews.GenericFailureView, props));
-    }
-
-    beforeEach(function() {
-      fakeAudio = {
-        play: sinon.spy(),
-        pause: sinon.spy(),
-        removeAttribute: sinon.spy()
-      };
-      navigator.mozLoop.doNotDisturb = false;
-      sandbox.stub(window, "Audio").returns(fakeAudio);
-    });
-
-    it("should play a failure sound, once", function() {
-      callView = mountTestComponent({cancelCall: function() {}});
-
-      sinon.assert.calledOnce(navigator.mozLoop.getAudioBlob);
-      sinon.assert.calledWithExactly(navigator.mozLoop.getAudioBlob,
-                                     "failure", sinon.match.func);
-      sinon.assert.calledOnce(fakeAudio.play);
-      expect(fakeAudio.loop).to.equal(false);
-    });
-
-    it("should set the title to generic_failure_title", function() {
-      callView = mountTestComponent({cancelCall: function() {}});
-
-      expect(fakeWindow.document.title).eql("generic_failure_title");
-    });
-
-    it("should show 'no media' for FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA reason",
-       function() {
-         callView = mountTestComponent({
-           cancelCall: function() {},
-           failureReason: FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA
-         });
-
-         expect(callView.getDOMNode().querySelector("h2").textContent)
-         .eql("no_media_failure_message");
-     });
-
-    it("should show 'no media' for FAILURE_DETAILS.NO_MEDIA reason", function() {
-      callView = mountTestComponent({
-        cancelCall: function() {},
-        failureReason: FAILURE_DETAILS.NO_MEDIA
-      });
-
-      expect(callView.getDOMNode().querySelector("h2").textContent)
-          .eql("no_media_failure_message");
-    });
-
-    it("should show 'generic_failure_title' when no reason is specified",
-       function() {
-         callView = mountTestComponent({cancelCall: function() {}});
-
-         expect(callView.getDOMNode().querySelector("h2").textContent)
-            .eql("generic_failure_title");
-     });
-  });
 });
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -5,16 +5,17 @@
 describe("loop.conversation", function() {
   "use strict";
 
   var expect = chai.expect;
   var FeedbackView = loop.feedbackViews.FeedbackView;
   var TestUtils = React.addons.TestUtils;
   var sharedActions = loop.shared.actions;
   var sharedModels = loop.shared.models;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var fakeWindow, sandbox, getLoopPrefStub, setLoopPrefStub, mozL10nGet;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     setLoopPrefStub = sandbox.stub();
 
     navigator.mozLoop = {
       doNotDisturb: true,
@@ -216,23 +217,30 @@ describe("loop.conversation", function()
       activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
         loop.roomViews.DesktopRoomConversationView);
     });
 
-    it("should display the GenericFailureView for failures", function() {
-      conversationAppStore.setStoreState({windowType: "failed"});
+    it("should display the DirectCallFailureView for failures", function() {
+      conversationAppStore.setStoreState({
+        contact: {},
+        outgoing: false,
+        windowType: "failed"
+      });
+      conversationStore.setStoreState({
+        callStateReason: FAILURE_DETAILS.UNKNOWN
+      });
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
-        loop.conversationViews.GenericFailureView);
+        loop.conversationViews.DirectCallFailureView);
     });
 
     it("should set the correct title when rendering feedback view", function() {
       conversationAppStore.setStoreState({showFeedbackForm: true});
 
       ccView = mountTestComponent();
 
       sinon.assert.calledWithExactly(mozL10nGet, "conversation_has_ended");
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -6,28 +6,31 @@ describe("loop.roomViews", function () {
 
   var expect = chai.expect;
   var TestUtils = React.addons.TestUtils;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
 
-  var sandbox, dispatcher, roomStore, activeRoomStore, fakeWindow,
-    fakeMozLoop, fakeContextURL;
+  var sandbox, dispatcher, roomStore, activeRoomStore, view;
+  var fakeWindow, fakeMozLoop, fakeContextURL;
   var favicon = "data:image/x-icon;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     dispatcher = new loop.Dispatcher();
 
     fakeMozLoop = {
-      getAudioBlob: sinon.stub(),
+      getAudioBlob: sinon.spy(function(name, callback) {
+        callback(null, new Blob([new ArrayBuffer(10)], {type: "audio/ogg"}));
+      }),
       getLoopPref: sinon.stub(),
       getSelectedTabMetadata: sinon.stub().callsArgWith(0, {
         favicon: favicon,
         previews: [],
         title: ""
       }),
       openURL: sinon.stub(),
       rooms: {
@@ -79,21 +82,23 @@ describe("loop.roomViews", function () {
       textChatStore: textChatStore
     });
 
     fakeContextURL = {
       description: "An invalid page",
       location: "http://invalid.com",
       thumbnail: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
     };
+    sandbox.stub(dispatcher, "dispatch");
   });
 
   afterEach(function() {
     sandbox.restore();
     loop.shared.mixins.setRootObject(window);
+    view = null;
   });
 
   describe("ActiveRoomStoreMixin", function() {
     it("should merge initial state", function() {
       var TestView = React.createClass({
         mixins: [loop.roomViews.ActiveRoomStoreMixin],
         getInitialState: function() {
           return {foo: "bar"};
@@ -123,27 +128,68 @@ describe("loop.roomViews", function () {
         }));
 
       activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
 
       expect(testView.state.roomState).eql(ROOM_STATES.READY);
     });
   });
 
-  describe("DesktopRoomInvitationView", function() {
-    var view;
+  describe("RoomFailureView", function() {
+    var fakeAudio;
+
+    function mountTestComponent(props) {
+      props = _.extend({
+        dispatcher: dispatcher,
+        failureReason: FAILURE_DETAILS.UNKNOWN
+      });
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.roomViews.RoomFailureView, props));
+    }
 
     beforeEach(function() {
-      sandbox.stub(dispatcher, "dispatch");
+      fakeAudio = {
+        play: sinon.spy(),
+        pause: sinon.spy(),
+        removeAttribute: sinon.spy()
+      };
+      sandbox.stub(window, "Audio").returns(fakeAudio);
+    });
+
+    it("should render the FailureInfoView", function() {
+      view = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(view,
+        loop.conversationViews.FailureInfoView);
     });
 
-    afterEach(function() {
-      view = null;
+    it("should dispatch a JoinRoom action when the rejoin call button is pressed", function() {
+      view = mountTestComponent();
+
+      var rejoinBtn = view.getDOMNode().querySelector(".btn-rejoin");
+
+      React.addons.TestUtils.Simulate.click(rejoinBtn);
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWithExactly(dispatcher.dispatch,
+        new sharedActions.JoinRoom());
     });
 
+    it("should play a failure sound, once", function() {
+      view = mountTestComponent();
+
+      sinon.assert.calledOnce(fakeMozLoop.getAudioBlob);
+      sinon.assert.calledWithExactly(fakeMozLoop.getAudioBlob,
+                                     "failure", sinon.match.func);
+      sinon.assert.calledOnce(fakeAudio.play);
+      expect(fakeAudio.loop).to.equal(false);
+    });
+  });
+
+  describe("DesktopRoomInvitationView", function() {
     function mountTestComponent(props) {
       props = _.extend({
         dispatcher: dispatcher,
         mozLoop: fakeMozLoop,
         roomData: {},
         savingContext: false,
         show: true,
         showEditContext: false
@@ -285,20 +331,19 @@ describe("loop.roomViews", function () {
         });
 
         expect(view.getDOMNode().querySelector(".room-context")).to.not.eql(null);
       });
     });
   });
 
   describe("DesktopRoomConversationView", function() {
-    var view, onCallTerminatedStub;
+    var onCallTerminatedStub;
 
     beforeEach(function() {
-      sandbox.stub(dispatcher, "dispatch");
       fakeMozLoop.getLoopPref = function(prefName) {
         if (prefName === "contextInConversations.enabled") {
           return true;
         }
         return "test";
       };
       onCallTerminatedStub = sandbox.stub();
     });
@@ -439,34 +484,40 @@ describe("loop.roomViews", function () {
       it("should set document.title to store.serverData.roomName", function() {
         mountTestComponent();
 
         activeRoomStore.setStoreState({roomName: "fakeName"});
 
         expect(fakeWindow.document.title).to.equal("fakeName");
       });
 
-      it("should render the GenericFailureView if the roomState is `FAILED`",
+      it("should render the RoomFailureView if the roomState is `FAILED`",
         function() {
-          activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
+          activeRoomStore.setStoreState({
+            failureReason: FAILURE_DETAILS.UNKNOWN,
+            roomState: ROOM_STATES.FAILED
+          });
 
           view = mountTestComponent();
 
           TestUtils.findRenderedComponentWithType(view,
-            loop.conversationViews.GenericFailureView);
+            loop.roomViews.RoomFailureView);
         });
 
-      it("should render the GenericFailureView if the roomState is `FULL`",
+      it("should render the RoomFailureView if the roomState is `FULL`",
         function() {
-          activeRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
+          activeRoomStore.setStoreState({
+            failureReason: FAILURE_DETAILS.UNKNOWN,
+            roomState: ROOM_STATES.FULL
+          });
 
           view = mountTestComponent();
 
           TestUtils.findRenderedComponentWithType(view,
-            loop.conversationViews.GenericFailureView);
+            loop.roomViews.RoomFailureView);
         });
 
       it("should render the DesktopRoomInvitationView if roomState is `JOINED`",
         function() {
           activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
 
           view = mountTestComponent();
 
@@ -666,30 +717,28 @@ describe("loop.roomViews", function () {
         React.addons.TestUtils.Simulate.click(editButton);
 
         expect(view.getDOMNode().querySelector(".room-context")).to.eql(null);
       });
     });
   });
 
   describe("SocialShareDropdown", function() {
-    var view, fakeProvider;
+    var fakeProvider;
 
     beforeEach(function() {
-      sandbox.stub(dispatcher, "dispatch");
-
       fakeProvider = {
         name: "foo",
         origin: "https://foo",
         iconURL: "http://example.com/foo.png"
       };
     });
 
     afterEach(function() {
-      view = fakeProvider = null;
+      fakeProvider = null;
     });
 
     function mountTestComponent(props) {
       props = _.extend({
         dispatcher: dispatcher,
         show: true
       }, props);
       return TestUtils.renderIntoDocument(
@@ -762,22 +811,16 @@ describe("loop.roomViews", function () {
             roomUrl: "http://example.com",
             previews: []
           }));
       });
     });
   });
 
   describe("DesktopRoomEditContextView", function() {
-    var view;
-
-    afterEach(function() {
-      view = null;
-    });
-
     function mountTestComponent(props) {
       props = _.extend({
         dispatcher: dispatcher,
         mozLoop: fakeMozLoop,
         savingContext: false,
         show: true,
         roomData: {
           roomToken: "fakeToken"
@@ -852,18 +895,16 @@ describe("loop.roomViews", function () {
         expect(node.querySelector(".checkbox-wrapper").classList.contains("hide")).to.eql(true);
       });
     });
 
     describe("Update Room", function() {
       var roomNameBox;
 
       beforeEach(function() {
-        sandbox.stub(dispatcher, "dispatch");
-
         view = mountTestComponent({
           editMode: true,
           roomData: {
             roomToken: "fakeToken",
             roomName: "fakeName",
             roomContextUrls: [fakeContextURL]
           }
         });
--- a/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
@@ -108,17 +108,17 @@ add_task(function* error_404() {
   yield MozLoopServiceInternal.hawkRequestInternal(LOOP_SESSION_TYPE.GUEST, "/404", "GET").then(
     () => Assert.ok(false, "Should have rejected"),
     (error) => {
       MozLoopServiceInternal.setError("testing", error);
       Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
 
       let err = MozLoopService.errors.get("testing");
       Assert.strictEqual(err.code, 404);
-      Assert.strictEqual(err.friendlyMessage, getLoopString("generic_failure_title"));
+      Assert.strictEqual(err.friendlyMessage, getLoopString("generic_failure_message"));
       Assert.equal(err.friendlyDetails, null);
   });
 });
 
 add_task(cleanup_between_tests);
 
 add_task(function* error_500() {
   yield MozLoopServiceInternal.hawkRequestInternal(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -21,17 +21,18 @@
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var ContactDropdown = loop.contacts.ContactDropdown;
   var ContactDetail = loop.contacts.ContactDetail;
   var GettingStartedView = loop.panel.GettingStartedView;
   // 1.2. Conversation Window
   var AcceptCallView = loop.conversationViews.AcceptCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
   var OngoingConversationView = loop.conversationViews.OngoingConversationView;
-  var CallFailedView = loop.conversationViews.CallFailedView;
+  var DirectCallFailureView = loop.conversationViews.DirectCallFailureView;
+  var RoomFailureView = loop.roomViews.RoomFailureView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
@@ -39,16 +40,17 @@
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var FeedbackView = loop.feedbackViews.FeedbackView;
   var Checkbox = loop.shared.views.Checkbox;
   var TextChatView = loop.shared.views.chat.TextChatView;
 
   // Store constants
   var ROOM_STATES = loop.store.ROOM_STATES;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
 
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
@@ -378,16 +380,24 @@
     return store;
   }
 
   var conversationStores = [];
   for (var index = 0; index < 5; index++) {
     conversationStores[index] = makeConversationStore();
   }
 
+  conversationStores[0].setStoreState({
+    callStateReason: FAILURE_DETAILS.NO_MEDIA
+  });
+  conversationStores[1].setStoreState({
+    callStateReason: FAILURE_DETAILS.USER_UNAVAILABLE,
+    contact: fakeManyContacts[0]
+  });
+
   // Update the text chat store with the room info.
   textChatStore.updateRoomInfo(new sharedActions.UpdateRoomInfo({
     roomName: "A Very Long Conversation Name",
     roomUrl: "http://showcase",
     urls: [{
       description: "A wonderful page!",
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
@@ -1145,45 +1155,52 @@
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopPendingConversationView, {callState: "gather", 
                                                 contact: mockContact, 
                                                 dispatcher: dispatcher})
               )
             )
           ), 
 
-          React.createElement(Section, {name: "CallFailedView"}, 
-            React.createElement(FramedExample, {dashed: true, 
-                           height: 272, 
-                           summary: "Call Failed - Incoming", 
-                           width: 300}, 
+          React.createElement(Section, {name: "DirectCallFailureView"}, 
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 254, 
+              summary: "Call Failed - Incoming", 
+              width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
-                React.createElement(CallFailedView, {dispatcher: dispatcher, 
-                                outgoing: false, 
-                                store: conversationStores[0]})
+                React.createElement(DirectCallFailureView, {
+                  conversationStore: conversationStores[0], 
+                  dispatcher: dispatcher, 
+                  outgoing: false})
               )
             ), 
-            React.createElement(FramedExample, {dashed: true, 
-                           height: 272, 
-                           summary: "Call Failed - Outgoing", 
-                           width: 300}, 
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 254, 
+              summary: "Call Failed - Outgoing", 
+              width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
-                React.createElement(CallFailedView, {dispatcher: dispatcher, 
-                                outgoing: true, 
-                                store: conversationStores[1]})
+                React.createElement(DirectCallFailureView, {
+                  conversationStore: conversationStores[1], 
+                  dispatcher: dispatcher, 
+                  outgoing: true})
               )
             ), 
-            React.createElement(FramedExample, {dashed: true, 
-                           height: 272, 
-                           summary: "Call Failed — with call URL error", 
-                           width: 300}, 
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 254, 
+              summary: "Call Failed — with call URL error", 
+              width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
-                React.createElement(CallFailedView, {dispatcher: dispatcher, emailLinkError: true, 
-                                outgoing: true, 
-                                store: conversationStores[0]})
+                React.createElement(DirectCallFailureView, {
+                  conversationStore: conversationStores[0], 
+                  dispatcher: dispatcher, 
+                  emailLinkError: true, 
+                  outgoing: true})
               )
             )
           ), 
 
           React.createElement(Section, {name: "OngoingConversationView"}, 
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: conversationStores[0].forcedUpdate, 
@@ -1329,16 +1346,30 @@
                            summary: "Standalone Unsupported Device", 
                            width: 480}, 
               React.createElement("div", {className: "standalone"}, 
                 React.createElement(UnsupportedDeviceView, {platform: "ios"})
               )
             )
           ), 
 
+          React.createElement(Section, {name: "RoomFailureView"}, 
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 254, 
+              summary: "", 
+              width: 298}, 
+              React.createElement("div", {className: "fx-embedded"}, 
+                React.createElement(RoomFailureView, {
+                  dispatcher: dispatcher, 
+                  failureReason: FAILURE_DETAILS.UNKNOWN})
+              )
+            )
+          ), 
+
           React.createElement(Section, {name: "DesktopRoomConversationView"}, 
             React.createElement(FramedExample, {height: 398, 
                            onContentsRendered: invitationRoomStore.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation (invitation, text-chat inclusion/scrollbars don't happen in real client)", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
                   dispatcher: dispatcher, 
@@ -1757,17 +1788,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 3;
+      var expectedWarningsCount = 0;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       var resultsElement = document.querySelector("#results");
       var divFailuresNode = document.createElement("div");
       var pCompleteNode = document.createElement("p");
       var emNode = document.createElement("em");
 
       if (uncaughtError || warningsMismatch) {
         var liTestFail = document.createElement("li");
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -21,17 +21,18 @@
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var ContactDropdown = loop.contacts.ContactDropdown;
   var ContactDetail = loop.contacts.ContactDetail;
   var GettingStartedView = loop.panel.GettingStartedView;
   // 1.2. Conversation Window
   var AcceptCallView = loop.conversationViews.AcceptCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
   var OngoingConversationView = loop.conversationViews.OngoingConversationView;
-  var CallFailedView = loop.conversationViews.CallFailedView;
+  var DirectCallFailureView = loop.conversationViews.DirectCallFailureView;
+  var RoomFailureView = loop.roomViews.RoomFailureView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
@@ -39,16 +40,17 @@
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var FeedbackView = loop.feedbackViews.FeedbackView;
   var Checkbox = loop.shared.views.Checkbox;
   var TextChatView = loop.shared.views.chat.TextChatView;
 
   // Store constants
   var ROOM_STATES = loop.store.ROOM_STATES;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
 
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
@@ -378,16 +380,24 @@
     return store;
   }
 
   var conversationStores = [];
   for (var index = 0; index < 5; index++) {
     conversationStores[index] = makeConversationStore();
   }
 
+  conversationStores[0].setStoreState({
+    callStateReason: FAILURE_DETAILS.NO_MEDIA
+  });
+  conversationStores[1].setStoreState({
+    callStateReason: FAILURE_DETAILS.USER_UNAVAILABLE,
+    contact: fakeManyContacts[0]
+  });
+
   // Update the text chat store with the room info.
   textChatStore.updateRoomInfo(new sharedActions.UpdateRoomInfo({
     roomName: "A Very Long Conversation Name",
     roomUrl: "http://showcase",
     urls: [{
       description: "A wonderful page!",
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
@@ -1145,45 +1155,52 @@
               <div className="fx-embedded">
                 <DesktopPendingConversationView callState={"gather"}
                                                 contact={mockContact}
                                                 dispatcher={dispatcher} />
               </div>
             </FramedExample>
           </Section>
 
-          <Section name="CallFailedView">
-            <FramedExample dashed={true}
-                           height={272}
-                           summary="Call Failed - Incoming"
-                           width={300}>
+          <Section name="DirectCallFailureView">
+            <FramedExample
+              dashed={true}
+              height={254}
+              summary="Call Failed - Incoming"
+              width={298}>
               <div className="fx-embedded">
-                <CallFailedView dispatcher={dispatcher}
-                                outgoing={false}
-                                store={conversationStores[0]} />
+                <DirectCallFailureView
+                  conversationStore={conversationStores[0]}
+                  dispatcher={dispatcher}
+                  outgoing={false} />
               </div>
             </FramedExample>
-            <FramedExample dashed={true}
-                           height={272}
-                           summary="Call Failed - Outgoing"
-                           width={300}>
+            <FramedExample
+              dashed={true}
+              height={254}
+              summary="Call Failed - Outgoing"
+              width={298}>
               <div className="fx-embedded">
-                <CallFailedView dispatcher={dispatcher}
-                                outgoing={true}
-                                store={conversationStores[1]} />
+                <DirectCallFailureView
+                  conversationStore={conversationStores[1]}
+                  dispatcher={dispatcher}
+                  outgoing={true} />
               </div>
             </FramedExample>
-            <FramedExample dashed={true}
-                           height={272}
-                           summary="Call Failed — with call URL error"
-                           width={300}>
+            <FramedExample
+              dashed={true}
+              height={254}
+              summary="Call Failed — with call URL error"
+              width={298}>
               <div className="fx-embedded">
-                <CallFailedView dispatcher={dispatcher} emailLinkError={true}
-                                outgoing={true}
-                                store={conversationStores[0]} />
+                <DirectCallFailureView
+                  conversationStore={conversationStores[0]}
+                  dispatcher={dispatcher}
+                  emailLinkError={true}
+                  outgoing={true} />
               </div>
             </FramedExample>
           </Section>
 
           <Section name="OngoingConversationView">
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={conversationStores[0].forcedUpdate}
@@ -1329,16 +1346,30 @@
                            summary="Standalone Unsupported Device"
                            width={480}>
               <div className="standalone">
                 <UnsupportedDeviceView platform="ios"/>
               </div>
             </FramedExample>
           </Section>
 
+          <Section name="RoomFailureView">
+            <FramedExample
+              dashed={true}
+              height={254}
+              summary=""
+              width={298}>
+              <div className="fx-embedded">
+                <RoomFailureView
+                  dispatcher={dispatcher}
+                  failureReason={FAILURE_DETAILS.UNKNOWN} />
+              </div>
+            </FramedExample>
+          </Section>
+
           <Section name="DesktopRoomConversationView">
             <FramedExample height={398}
                            onContentsRendered={invitationRoomStore.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation (invitation, text-chat inclusion/scrollbars don't happen in real client)"
                            width={298}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
                   dispatcher={dispatcher}
@@ -1757,17 +1788,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 3;
+      var expectedWarningsCount = 0;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       var resultsElement = document.querySelector("#results");
       var divFailuresNode = document.createElement("div");
       var pCompleteNode = document.createElement("p");
       var emNode = document.createElement("em");
 
       if (uncaughtError || warningsMismatch) {
         var liTestFail = document.createElement("li");
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -258,32 +258,33 @@ conversation_has_ended=Your conversation
 restart_call=Rejoin
 
 ## LOCALIZATION NOTE (contact_unavailable_title): The title displayed
 ## when a contact is unavailable. Don't translate the part between {{..}}
 ## because this will be replaced by the contact's name.
 contact_unavailable_title={{contactName}} is unavailable.
 generic_contact_unavailable_title=This person is unavailable.
 
-generic_failure_title=Something went wrong.
+generic_failure_message=We're having technical difficulties…
 generic_failure_with_reason2=You can try again or email a link to be reached at later.
 generic_failure_no_reason2=Would you like to try again?
 
 ## LOCALIZATION NOTE (contact_offline_title): Title which is displayed when the
 ## contact is offline.
 contact_offline_title=This person is not online
 ## LOCALIZATION NOTE (call_timeout_notification_text): Title which is displayed
 ## when the call didn't go through.
 call_timeout_notification_text=Your call did not go through.
 
 ## LOCALIZATION NOTE (retry_call_button, cancel_button, email_link_button):
 ## These buttons are displayed when a call has failed.
 retry_call_button=Retry
 email_link_button=Email Link
 cancel_button=Cancel
+rejoin_button=Rejoin Conversation
 
 cannot_start_call_session_not_ready=Can't start call, session is not ready.
 network_disconnected=The network connection terminated abruptly.
 connection_error_see_console_notification=Call failed; see console for details.
 no_media_failure_message=No camera or microphone found.
 
 ## LOCALIZATION NOTE (legal_text_and_links3): In this item, don't translate the
 ## parts between {{..}} because these will be replaced with links with the labels