Bug 990678 - Add ability to make audio only calls in Loop standalone and desktop. r=Standard8
authorAndrei Oprea <andrei.br92@gmail.com>
Fri, 15 Aug 2014 19:45:31 +0100
changeset 199783 55b8364d966e746a35b67cef3512fed1866e8f79
parent 199782 e99b11f8b3f6e55e8e2daf574686ef9c6b18973f
child 199784 d18d3e14ac3ebce527241cdc585c1f92aed1e818
push id8233
push usermbanner@mozilla.com
push dateFri, 15 Aug 2014 18:46:03 +0000
treeherderfx-team@55b8364d966e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs990678
milestone34.0a1
Bug 990678 - Add ability to make audio only calls in Loop standalone and desktop. r=Standard8
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/img/audio-default-16x16@1.5x.png
browser/components/loop/content/shared/img/audio-default-16x16@2x.png
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/standalone/content/css/webapp.css
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/data.ini
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/test/standalone/webapp_test.js
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
@@ -27,34 +27,37 @@ loop.conversation = (function(OT, mozL10
       model: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {showDeclineMenu: false};
     },
 
     componentDidMount: function() {
-      window.addEventListener('click', this.clickHandler);
-      window.addEventListener('blur', this._hideDeclineMenu);
+      window.addEventListener("click", this.clickHandler);
+      window.addEventListener("blur", this._hideDeclineMenu);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener('click', this.clickHandler);
-      window.removeEventListener('blur', this._hideDeclineMenu);
+      window.removeEventListener("click", this.clickHandler);
+      window.removeEventListener("blur", this._hideDeclineMenu);
     },
 
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
-    _handleAccept: function() {
-      this.props.model.trigger("accept");
+    _handleAccept: function(callType) {
+      return () => {
+        this.props.model.set("selectedCallType", callType);
+        this.props.model.trigger("accept");
+      };
     },
 
     _handleDecline: function() {
       this.props.model.trigger("decline");
     },
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
@@ -69,50 +72,64 @@ loop.conversation = (function(OT, mozL10
     },
 
     _hideDeclineMenu: function() {
       this.setState({showDeclineMenu: false});
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-success btn-accept";
+      var btnClassAccept = "btn btn-success btn-accept call-audio-video";
       var btnClassBlock = "btn btn-error btn-block";
       var btnClassDecline = "btn btn-error btn-decline";
       var conversationPanelClass = "incoming-call " +
                                   loop.shared.utils.getTargetPlatform();
       var cx = React.addons.classSet;
-      var declineDropdownMenuClasses = cx({
+      var dropdownMenuClassesDecline = cx({
         "native-dropdown-menu": true,
-        "decline-block-menu": true,
+        "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showDeclineMenu
       });
       return (
         React.DOM.div({className: conversationPanelClass}, 
           React.DOM.h2(null, __("incoming_call")), 
           React.DOM.div({className: "button-group incoming-call-action-group"}, 
             React.DOM.div({className: "button-chevron-menu-group"}, 
               React.DOM.div({className: "button-group-chevron"}, 
                 React.DOM.div({className: "button-group"}, 
-                  React.DOM.button({className: btnClassDecline, onClick: this._handleDecline}, 
+
+                  React.DOM.button({className: btnClassDecline, 
+                          onClick: this._handleDecline}, 
                     __("incoming_call_decline_button")
                   ), 
                   React.DOM.div({className: "btn-chevron", 
-                    onClick: this._toggleDeclineMenu}
+                       onClick: this._toggleDeclineMenu}
                   )
                 ), 
-                React.DOM.ul({className: declineDropdownMenuClasses}, 
+
+                React.DOM.ul({className: dropdownMenuClassesDecline}, 
                   React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock}, 
                     __("incoming_call_decline_and_block_button")
                   )
                 )
+
               )
             ), 
-            React.DOM.button({className: btnClassAccept, onClick: this._handleAccept}, 
-              __("incoming_call_answer_button")
+
+            React.DOM.div({className: "button-chevron-menu-group"}, 
+              React.DOM.div({className: "button-group"}, 
+                React.DOM.button({className: btnClassAccept, 
+                        onClick: this._handleAccept("audio-video")}, 
+                  __("incoming_call_answer_button")
+                ), 
+                React.DOM.div({className: "call-audio-only", 
+                     onClick: this._handleAccept("audio"), 
+                     title: __("incoming_call_answer_audio_only_tooltip")}
+                )
+              )
             )
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
@@ -176,19 +193,20 @@ loop.conversation = (function(OT, mozL10
           this._notifier.errorL10n("cannot_start_call_session_not_ready");
           return;
         }
         // XXX For incoming calls we might have more than one call queued.
         // For now, we'll just assume the first call is the right information.
         // We'll probably really want to be getting this data from the
         // background worker on the desktop client.
         // Bug 1032700 should fix this.
-        this._conversation.setSessionData(sessionData[0]);
+        this._conversation.setIncomingSessionData(sessionData[0]);
         this.loadReactComponent(loop.conversation.IncomingCallView({
-          model: this._conversation
+          model: this._conversation,
+          video: {enabled: this._conversation.hasVideoStream("incoming")}
         }));
       });
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
@@ -208,17 +226,17 @@ loop.conversation = (function(OT, mozL10
     /**
      * Decline and block an incoming call
      * @note:
      * - loopToken is the callUrl identifier. It gets set in the panel
      *   after a callUrl is received
      */
     declineAndBlock: function() {
       navigator.mozLoop.stopAlerting();
-      var token = navigator.mozLoop.getLoopCharPref('loopToken');
+      var token = navigator.mozLoop.getLoopCharPref("loopToken");
       this._client.deleteCallUrl(token, function(error) {
         // XXX The conversation window will be closed when this cb is triggered
         // figure out if there is a better way to report the error to the user
         // (bug 1048909).
         console.log(error);
       });
       window.close();
     },
@@ -230,20 +248,24 @@ loop.conversation = (function(OT, mozL10
     conversation: function() {
       if (!this._conversation.isSessionReady()) {
         console.error("Error: navigated to conversation route without " +
           "the start route to initialise the call first");
         this._notifier.errorL10n("cannot_start_call_session_not_ready");
         return;
       }
 
+      var callType = this._conversation.get("selectedCallType");
+      var videoStream = callType === "audio" ? false : true;
+
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
-        model: this._conversation
+        model: this._conversation,
+        video: {enabled: videoStream}
       }));
     },
 
     /**
      * Call has ended, display a feedback form.
      */
     feedback: function() {
       document.title = mozL10n.get("call_has_ended");
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -27,34 +27,37 @@ loop.conversation = (function(OT, mozL10
       model: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {showDeclineMenu: false};
     },
 
     componentDidMount: function() {
-      window.addEventListener('click', this.clickHandler);
-      window.addEventListener('blur', this._hideDeclineMenu);
+      window.addEventListener("click", this.clickHandler);
+      window.addEventListener("blur", this._hideDeclineMenu);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener('click', this.clickHandler);
-      window.removeEventListener('blur', this._hideDeclineMenu);
+      window.removeEventListener("click", this.clickHandler);
+      window.removeEventListener("blur", this._hideDeclineMenu);
     },
 
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
-    _handleAccept: function() {
-      this.props.model.trigger("accept");
+    _handleAccept: function(callType) {
+      return () => {
+        this.props.model.set("selectedCallType", callType);
+        this.props.model.trigger("accept");
+      };
     },
 
     _handleDecline: function() {
       this.props.model.trigger("decline");
     },
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
@@ -69,51 +72,65 @@ loop.conversation = (function(OT, mozL10
     },
 
     _hideDeclineMenu: function() {
       this.setState({showDeclineMenu: false});
     },
 
     render: function() {
       /* jshint ignore:start */
-      var btnClassAccept = "btn btn-success btn-accept";
+      var btnClassAccept = "btn btn-success btn-accept call-audio-video";
       var btnClassBlock = "btn btn-error btn-block";
       var btnClassDecline = "btn btn-error btn-decline";
       var conversationPanelClass = "incoming-call " +
                                   loop.shared.utils.getTargetPlatform();
       var cx = React.addons.classSet;
-      var declineDropdownMenuClasses = cx({
+      var dropdownMenuClassesDecline = cx({
         "native-dropdown-menu": true,
-        "decline-block-menu": true,
+        "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showDeclineMenu
       });
       return (
         <div className={conversationPanelClass}>
           <h2>{__("incoming_call")}</h2>
           <div className="button-group incoming-call-action-group">
             <div className="button-chevron-menu-group">
               <div className="button-group-chevron">
                 <div className="button-group">
-                  <button className={btnClassDecline} onClick={this._handleDecline}>
+
+                  <button className={btnClassDecline}
+                          onClick={this._handleDecline}>
                     {__("incoming_call_decline_button")}
                   </button>
                   <div className="btn-chevron"
-                    onClick={this._toggleDeclineMenu}>
+                       onClick={this._toggleDeclineMenu}>
                   </div>
                 </div>
-                <ul className={declineDropdownMenuClasses}>
+
+                <ul className={dropdownMenuClassesDecline}>
                   <li className="btn-block" onClick={this._handleDeclineBlock}>
                     {__("incoming_call_decline_and_block_button")}
                   </li>
                 </ul>
+
               </div>
             </div>
-            <button className={btnClassAccept} onClick={this._handleAccept}>
-              {__("incoming_call_answer_button")}
-            </button>
+
+            <div className="button-chevron-menu-group">
+              <div className="button-group">
+                <button className={btnClassAccept}
+                        onClick={this._handleAccept("audio-video")}>
+                  {__("incoming_call_answer_button")}
+                </button>
+                <div className="call-audio-only"
+                     onClick={this._handleAccept("audio")}
+                     title={__("incoming_call_answer_audio_only_tooltip")} >
+                </div>
+              </div>
+            </div>
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
   /**
@@ -176,19 +193,20 @@ loop.conversation = (function(OT, mozL10
           this._notifier.errorL10n("cannot_start_call_session_not_ready");
           return;
         }
         // XXX For incoming calls we might have more than one call queued.
         // For now, we'll just assume the first call is the right information.
         // We'll probably really want to be getting this data from the
         // background worker on the desktop client.
         // Bug 1032700 should fix this.
-        this._conversation.setSessionData(sessionData[0]);
+        this._conversation.setIncomingSessionData(sessionData[0]);
         this.loadReactComponent(loop.conversation.IncomingCallView({
-          model: this._conversation
+          model: this._conversation,
+          video: {enabled: this._conversation.hasVideoStream("incoming")}
         }));
       });
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
@@ -208,17 +226,17 @@ loop.conversation = (function(OT, mozL10
     /**
      * Decline and block an incoming call
      * @note:
      * - loopToken is the callUrl identifier. It gets set in the panel
      *   after a callUrl is received
      */
     declineAndBlock: function() {
       navigator.mozLoop.stopAlerting();
-      var token = navigator.mozLoop.getLoopCharPref('loopToken');
+      var token = navigator.mozLoop.getLoopCharPref("loopToken");
       this._client.deleteCallUrl(token, function(error) {
         // XXX The conversation window will be closed when this cb is triggered
         // figure out if there is a better way to report the error to the user
         // (bug 1048909).
         console.log(error);
       });
       window.close();
     },
@@ -230,20 +248,24 @@ loop.conversation = (function(OT, mozL10
     conversation: function() {
       if (!this._conversation.isSessionReady()) {
         console.error("Error: navigated to conversation route without " +
           "the start route to initialise the call first");
         this._notifier.errorL10n("cannot_start_call_session_not_ready");
         return;
       }
 
+      var callType = this._conversation.get("selectedCallType");
+      var videoStream = callType === "audio" ? false : true;
+
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
-        model: this._conversation
+        model: this._conversation,
+        video: {enabled: videoStream}
       }));
     },
 
     /**
      * Call has ended, display a feedback form.
      */
     feedback: function() {
       document.title = mozL10n.get("call_has_ended");
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -104,16 +104,21 @@ h1, h2, h3 {
 .btn-large {
   /* Dimensions from spec
    * https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */
   padding: .5em;
   font-size: 18px;
   height: auto;
 }
 
+  .btn-large + .btn-chevron {
+    padding: 1rem;
+    height: 100%; /* match full height of button */
+  }
+
 /*
  * Left / Right padding elements
  * used to center components
  * */
 .flex-padding-1 {
   display: flex;
   flex: 1;
 }
@@ -128,27 +133,30 @@ h1, h2, h3 {
     border: 1px solid #008acb;
   }
 
   .btn-info:active {
     background-color: #006b9d;
     border: 1px solid #006b9d;
   }
 
-.btn-success {
+.btn-success,
+.btn-success + .btn-chevron {
   background-color: #74bf43;
   border: 1px solid #74bf43;
 }
 
-  .btn-success:hover {
+  .btn-success:hover,
+  .btn-success + .btn-chevron:hover {
     background-color: #6cb23e;
     border: 1px solid #6cb23e;
   }
 
-  .btn-success:active {
+  .btn-success:active,
+  .btn-success + .btn-chevron:active {
     background-color: #64a43a;
     border: 1px solid #64a43a;
   }
 
 .btn-warning {
   background-color: #f0ad4e;
 }
 
@@ -229,16 +237,18 @@ h1, h2, h3 {
   display: flex;
   width: 100%;
   align-content: space-between;
   justify-content: center;
 }
 
 .button-group .btn {
   flex: 1;
+  border-bottom-right-radius: 0;
+  border-top-right-radius: 0;
 }
 
 /* Alerts */
 .alert {
   background: #eee;
   padding: .2em 1em;
   margin-bottom: 1em;
 }
@@ -287,34 +297,46 @@ h1, h2, h3 {
 }
 
 /* Transitions */
 .fade-out {
   transition: opacity 0.5s ease-in;
   opacity: 0;
 }
 
-.btn-large .icon {
-  display: inline-block;
-  width: 20px;
-  height: 20px;
+.icon,
+.icon-small,
+.icon-audio,
+.icon-video {
   background-size: 20px;
   background-repeat: no-repeat;
   vertical-align: top;
-  margin-left: 10px;
+  background-position: 80% center;
+}
+
+.icon-small {
+  background-size: 10px;
 }
 
 .icon-video {
   background-image: url("../img/video-inverse-14x14.png");
 }
 
+.icon-audio {
+  background-image: url("../img/audio-default-16x16@1.5x.png");
+}
+
 @media (min-resolution: 2dppx) {
   .icon-video {
     background-image: url("../img/video-inverse-14x14@2x.png");
   }
+
+  .icon-audio {
+    background-image: url("../img/audio-default-16x16@2x.png");
+  }
 }
 
 /*
  * Platform specific styles
  * The UI should match the user OS
  * Specific font sizes for text paragraphs to match
  * the interface on that platform.
  */
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -189,16 +189,51 @@
   margin-left: .5em;
 }
 
 .incoming-call h2 {
   font-size: 1.5em;
   font-weight: normal;
 }
 
+.call-audio-only {
+  width: 26px;
+  height: 26px;
+  border-left: 1px solid rgba(255,255,255,.4);
+  border-top-right-radius: 2px;
+  border-bottom-right-radius: 2px;
+  background-color: #74BF43;
+  background-image: url("../img/audio-inverse-14x14.png");
+  background-size: 1rem;
+  background-position: center;
+  background-repeat: no-repeat;
+  cursor: pointer;
+}
+
+  .call-audio-only:hover {
+    background-color: #6cb23e;
+  }
+
+
+.call-audio-video {
+  background-image: url("../img/video-inverse-14x14.png");
+  background-position: 96% center;
+  background-repeat: no-repeat;
+  background-size: 1rem;
+}
+
+@media (min-resolution: 2dppx) {
+  .call-audio-only {
+    background-image: url("../img/audio-inverse-14x14@2x.png");
+  }
+  .call-audio-video {
+    background-image: url("../img/video-inverse-14x14@2x.png");
+  }
+}
+
 /* Expired call url page */
 
 .expired-url-info {
   width: 400px;
   margin: 0 auto;
 }
 
 .promote-firefox {
@@ -207,43 +242,66 @@
   line-height: 24px;
   margin: 2em 0;
 }
 
 .promote-firefox h3 {
   font-weight: 300;
 }
 
-/* Block incoming call */
+/*
+ * Dropdown menu hidden behind a chevron
+ *
+ * .native-dropdown-menu[-large-parent] Generic class, contains common styles
+ * .standalone-dropdown-menu Initiate call dropdown menu
+ * .conversation-window-dropdown Dropdown menu for answer/decline/block options
+ */
 
-.native-dropdown-menu {
+.native-dropdown-menu,
+.native-dropdown-large-parent {
   /* Should match a native select menu */
   padding: 0;
   position: absolute; /* element can be wider than the parent */
   background: #fff;
   margin: 0;
   box-shadow: 0 4px 5px rgba(30, 30, 30, .3);
   border-style: solid;
   border-width: 1px 1px 1px 2px;
   border-color: #aaa #111 #111 #aaa;
 }
 
-.decline-block-menu li {
+  /*
+   * If the component is smaller than the parent
+   * we need it to display block to occupy full width
+   * Same as above but overrides apropriate styles
+   */
+  .native-dropdown-large-parent {
+    position: relative;
+    display: block;
+  }
+
+  .native-dropdown-menu li,
+  .native-dropdown-large-parent li {
+    list-style: none;
+    cursor: pointer;
+    color: #000;
+  }
+
+  .native-dropdown-menu li:hover,
+  .native-dropdown-large-parent li:hover,
+  .native-dropdown-large-parent li:hover button {
+    color: #fff;
+    background-color: #111;
+  }
+
+.conversation-window-dropdown li {
   padding: 0 10px 0 5px;
-  list-style: none;
   font-size: .9em;
-  color: #000;
-  cursor: pointer;
 }
 
-  .decline-block-menu li:hover {
-    color: #FFF;
-    background: #111;
-  }
-
 /* Expired call url page */
 
 .expired-url-info {
   width: 400px;
   margin: 0 auto;
 }
 
 .promote-firefox {
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..fdef44157a2a18c0362e1219341de82a3ffdc12f
GIT binary patch
literal 424
zc$@*K0ayNsP)<h;3K|Lk000e1NJLTq000;O000;W1ONa4N`Cco0004RNkl<Zcmd7S
zv1=4T0EO}IjdKtKiU>lmNh61Xuwtw%6l0S@1w{$QG9X%sg;)uKofcxJe}SMy8Y{s@
z!A8O4MB$292o@58#3Gv8)n~EGF|&6~I)7jDn(1anjF>+YZmRGm@3~8l7G0+0zHZTQ
zr3Ido(E=&Y>J_P<?(&^PFln44w_B$~S`?W>v>hZ9$yg$1k%?w|0{`y;Ut^g$#?Wn+
zecaSVQwviR>@j2PSD{@rVqkkB4VU6?M<p#T-3hoHy&8Q`!3u3X6;Smn>OwQ{2hi#2
znFpl4($i6|#cz?iFdVC1;z*qHb#Kw|+MjtnVuA-_-^14QM9-7ltCr#-1)F+TK8<&D
zg^&FiN*bDhp#z6^ykOd^eyJ5ts^F~l^7_{W))~@yQgL6r!J?+9efHz(2?G+kMBA56
zcSAeDZ#cn-7-{R4GJpn*5k%HiebNsm0esTye(PD2l>Zt&riZlXcX^QwhW-JB9goF0
S*`kL40000<MNUMnLSTY#O}w)J
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..71ed8d8e3bf9df9217455979547bfbf64bf274b8
GIT binary patch
literal 536
zc$@(k0_XjSP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F80005sNkl<ZSi@sr
zAQ6OyhVn;7Mz#R){-~&^cR>6Q$le$c5s?IxW@Mlx09xV=wCp+1(*M{nQ0^E|jRH+9
z4-XI51X}VHm*q(K3^0_~sO|uu?@kk8IT!;&p`NOi0}WLr)^cc6+@iMcJxFxGZ)zt#
zKT;e3!a!|IqYfB#z^DT#wKN-w4as!?P+uU(3}7+tLr&=jD^$wJbpTNBA*dN!MjbHp
z901hx4XU{j=m2LDE2D2vPn?B{ts=((cc7Y=0vi|-K>9BUjyMGh0cevA<e&-UIAAW+
zu=frQ4je%KN)j3*vB;qY%8ule0}e=r)&j90r~yhu@Gk>3PJ!&>Q2r}lUtcbA8zQ@4
z4Gdsm;t6Cc0r5^;jSwL20$B#6`;p}G$u13naW4&P1q10nK->!y<AJu2nnBHRAYKB*
zr9g2lXwz*ElB2d#+*Aifg)GpZi%5n(0!2w!SeP9!RFpv$gNj<9x@kbUA4uxv8XFt4
zQl&D2`WocZKSU(L_dv%K(xh(yiV{$h8<eoHSbQERmjG15$v|rWv}_KRL>Rz8FEAhu
z00ylt(2{DPWmACkH7rrE4OHR*>72m8Kw)ZkOe#sN3qAm`4OJ`$`bG?B=vttG>qx{z
amHz-e&RiG!)_<%30000<MNUMnLSTZtwbGCP
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -9,27 +9,31 @@ loop.shared = loop.shared || {};
 loop.shared.models = (function() {
   "use strict";
 
   /**
    * Conversation model.
    */
   var ConversationModel = Backbone.Model.extend({
     defaults: {
-      connected:    false,     // Session connected flag
-      ongoing:      false,     // Ongoing call flag
-      callerId:     undefined, // Loop caller id
-      loopToken:    undefined, // Loop conversation token
-      loopVersion:  undefined, // Loop version for /calls/ information. This
-                               // is the version received from the push
-                               // notification and is used by the server to
-                               // determine the pending calls
-      sessionId:    undefined, // OT session id
-      sessionToken: undefined, // OT session token
-      apiKey:       undefined  // OT api key
+      connected:    false,         // Session connected flag
+      ongoing:      false,         // Ongoing call flag
+      callerId:     undefined,     // Loop caller id
+      loopToken:    undefined,     // Loop conversation token
+      loopVersion:  undefined,     // Loop version for /calls/ information. This
+                                   // is the version received from the push
+                                   // notification and is used by the server to
+                                   // determine the pending calls
+      sessionId:    undefined,     // OT session id
+      sessionToken: undefined,     // OT session token
+      apiKey:       undefined,     // OT api key
+      callType:     undefined,     // The type of incoming call selected by
+                                   // other peer ("audio" or "audio-video")
+      selectedCallType: undefined  // The selected type for the call that was
+                                   // initiated ("audio" or "audio-video")
     },
 
     /**
      * SDK object.
      * @type {OT}
      */
     sdk: undefined,
 
@@ -109,44 +113,60 @@ loop.shared.models = (function() {
           this.trigger("timeout").endSession();
         }
       }
 
       // Setup pending call timeout.
       this._pendingCallTimer = setTimeout(
         handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
 
-      this.setSessionData(sessionData);
+      this.setOutgoingSessionData(sessionData);
       this.trigger("call:outgoing");
     },
 
     /**
      * Checks that the session is ready.
      *
      * @return {Boolean}
      */
     isSessionReady: function() {
       return !!this.get("sessionId");
     },
 
     /**
      * Sets session information.
+     * Session data received by creating an outgoing call.
      *
      * @param {Object} sessionData Conversation session information.
      */
-    setSessionData: function(sessionData) {
+    setOutgoingSessionData: function(sessionData) {
       // Explicit property assignment to prevent later "surprises"
       this.set({
         sessionId:    sessionData.sessionId,
         sessionToken: sessionData.sessionToken,
         apiKey:       sessionData.apiKey
       });
     },
 
     /**
+     * Sets session information about the incoming call.
+     *
+     * @param {Object} sessionData Conversation session information.
+     */
+    setIncomingSessionData: function(sessionData) {
+      // Explicit property assignment to prevent later "surprises"
+      this.set({
+        sessionId:    sessionData.sessionId,
+        sessionToken: sessionData.sessionToken,
+        apiKey:       sessionData.apiKey,
+        callType:     sessionData.callType || "audio-video"
+      });
+    },
+
+    /**
      * Starts a SDK session and subscribe to call events.
      */
     startSession: function() {
       if (!this.isSessionReady()) {
         throw new Error("Can't start session as it's not ready");
       }
       this.session = this.sdk.initSession(this.get("sessionId"));
       this.listenTo(this.session, "streamCreated", this._streamCreated);
@@ -165,16 +185,32 @@ loop.shared.models = (function() {
      */
     endSession: function() {
       this.session.disconnect();
       this.set("ongoing", false)
           .once("session:ended", this.stopListening, this);
     },
 
     /**
+     * Helper function to determine if video stream is available for the
+     * incoming or outgoing call
+     *
+     * @param {string} callType Incoming or outgoing call
+     */
+    hasVideoStream: function(callType) {
+      if (callType === "incoming") {
+        return this.get("callType") === "audio-video";
+      }
+      if (callType === "outgoing") {
+        return this.get("selectedCallType") === "audio-video";
+      }
+      return undefined;
+    },
+
+    /**
      * Handle a loop-server error, which has an optional `errno` property which
      * is server error identifier.
      *
      * Triggers the following events:
      *
      * - `session:expired` for expired call urls
      * - `session:error` for other generic errors
      *
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -212,23 +212,34 @@ loop.shared.views = (function(_, OT, l10
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
+    getInitialProps: function() {
+      return {
+        video: {enabled: true},
+        audio: {enabled: true}
+      };
+    },
+
     getInitialState: function() {
       return {
-        video: {enabled: false},
-        audio: {enabled: false}
+        video: this.props.video,
+        audio: this.props.audio
       };
     },
 
+    componentWillMount: function() {
+      this.publisherConfig.publishVideo = this.props.video.enabled;
+    },
+
     componentDidMount: function() {
       this.listenTo(this.props.model, "session:connected",
                                       this.startPublishing);
       this.listenTo(this.props.model, "session:stream-created",
                                       this._streamCreated);
       this.listenTo(this.props.model, ["session:peer-hungup",
                                        "session:network-disconnected",
                                        "session:ended"].join(" "),
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -212,23 +212,34 @@ loop.shared.views = (function(_, OT, l10
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
+    getInitialProps: function() {
+      return {
+        video: {enabled: true},
+        audio: {enabled: true}
+      };
+    },
+
     getInitialState: function() {
       return {
-        video: {enabled: false},
-        audio: {enabled: false}
+        video: this.props.video,
+        audio: this.props.audio
       };
     },
 
+    componentWillMount: function() {
+      this.publisherConfig.publishVideo = this.props.video.enabled;
+    },
+
     componentDidMount: function() {
       this.listenTo(this.props.model, "session:connected",
                                       this.startPublishing);
       this.listenTo(this.props.model, "session:stream-created",
                                       this._streamCreated);
       this.listenTo(this.props.model, ["session:peer-hungup",
                                        "session:network-disconnected",
                                        "session:ended"].join(" "),
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -119,8 +119,44 @@ header {
   font-weight: lighter;
 }
 
 .light-color-font {
   opacity: .4;
   font-weight: normal;
 }
 
+.start-audio-only-call,
+.start-audio-video-call {
+  background-color: none;
+  background-image: url("../shared/img/audio-default-16x16@1.5x.png");
+  background-position: 80% center;
+  background-size: 10px;
+  background-repeat: no-repeat;
+  cursor: pointer;
+}
+
+.start-audio-only-call {
+  border: none;
+  width: 100%;
+}
+
+.start-audio-only-call:hover {
+  background-image: url("../shared/img/audio-inverse-14x14.png");
+}
+
+.start-audio-video-call {
+  background-size: 20px;
+  background-image: url("../shared/img/video-inverse-14x14.png");
+}
+
+@media (min-resolution: 2dppx) {
+  .start-audio-only-call {
+    background-image: url("../shared/img/audio-default-16x16@2x.png");
+  }
+  .start-audio-only-call:hover {
+    background-image: url("../shared/img/audio-inverse-14x14@2x.png");
+  }
+  .start-audio-video-call {
+    background-image: url("../shared/img/video-inverse-14x14@2x.png");
+  }
+}
+
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -139,76 +139,109 @@ loop.webapp = (function($, _, OT, webL10
      * - {loop.shared.model.ConversationModel}    model    Conversation model.
      * - {loop.shared.views.NotificationListView} notifier Notifier component.
      *
      */
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false
+        disableCallButton: false,
+        showCallOptionsMenu: false
       };
     },
 
     propTypes: {
       model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                                        .isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifier: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired
     },
 
     componentDidMount: function() {
+      // Listen for events & hide dropdown menu if user clicks away
+      window.addEventListener("click", this.clickHandler);
       this.props.model.listenTo(this.props.model, "session:error",
                                 this._onSessionError);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
       // XXX DOM element does not exist before React view gets instantiated
       // We should turn the notifier into a react component
       this.props.notifier.$el = $("#messages");
     },
 
     _onSessionError: function(error) {
       console.error(error);
       this.props.notifier.errorL10n("unable_retrieve_call_info");
     },
 
     /**
      * Initiates the call.
+     * Takes in a call type parameter "audio" or "audio-video" and returns
+     * a function that initiates the call. React click handler requires a function
+     * to be called when that event happenes.
+     *
+     * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function() {
-      this.setState({disableCallButton: true});
-      this.props.model.setupOutgoingCall();
+    _initiateOutgoingCall: function(callType) {
+      return function() {
+        this.props.model.set("selectedCallType", callType);
+        this.setState({disableCallButton: true});
+        this.props.model.setupOutgoingCall();
+      }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifier.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
 
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
+    componentWillUnmount: function() {
+      window.removeEventListener("click", this.clickHandler);
+    },
+
+    clickHandler: function(e) {
+      if (!e.target.classList.contains('btn-chevron') &&
+          this.state.showCallOptionsMenu) {
+            this._toggleCallOptionsMenu();
+      }
+    },
+
+    _toggleCallOptionsMenu: function() {
+      var state = this.state.showCallOptionsMenu;
+      this.setState({showCallOptionsMenu: !state});
+    },
+
     render: function() {
       var tos_link_name = __("terms_of_use_link_text");
       var privacy_notice_name = __("privacy_notice_link_text");
 
       var tosHTML = __("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='" +
           "https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
           "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
       });
 
-      var callButtonClasses = "btn btn-success btn-large " +
+      var btnClassStartCall = "btn btn-large btn-success " +
+                              "start-audio-video-call " +
                               loop.shared.utils.getTargetPlatform();
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showCallOptionsMenu
+      });
 
       return (
         /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
@@ -216,21 +249,47 @@ loop.webapp = (function($, _, OT, webL10
             React.DOM.p({className: "large-font light-weight-font"}, 
               __("initiate_call_button_label")
             ), 
 
             React.DOM.div({id: "messages"}), 
 
             React.DOM.div({className: "button-group"}, 
               React.DOM.div({className: "flex-padding-1"}), 
-              React.DOM.button({ref: "submitButton", onClick: this._initiateOutgoingCall, 
-                className: callButtonClasses, 
-                disabled: this.state.disableCallButton}, 
-                __("initiate_call_button"), 
-                React.DOM.i({className: "icon icon-video"})
+              React.DOM.div({className: "button-chevron-menu-group"}, 
+                React.DOM.div({className: "button-group-chevron"}, 
+                  React.DOM.div({className: "button-group"}, 
+
+                    React.DOM.button({className: btnClassStartCall, 
+                            onClick: this._initiateOutgoingCall("audio-video"), 
+                            disabled: this.state.disableCallButton, 
+                            title: __("initiate_audio_video_call_tooltip")}, 
+                      __("initiate_audio_video_call_button")
+                    ), 
+
+                    React.DOM.div({className: "btn-chevron", 
+                      onClick: this._toggleCallOptionsMenu}
+                    )
+
+                  ), 
+
+                  React.DOM.ul({className: dropdownMenuClasses}, 
+                    React.DOM.li(null, 
+                      /*
+                       Button required for disabled state.
+                       */
+                      React.DOM.button({className: "start-audio-only-call", 
+                              onClick: this._initiateOutgoingCall("audio"), 
+                              disabled: this.state.disableCallButton}, 
+                        __("initiate_audio_call_button")
+                      )
+                    )
+                  )
+
+                )
               ), 
               React.DOM.div({className: "flex-padding-1"})
             ), 
 
             React.DOM.p({className: "terms-service", 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
@@ -275,22 +334,22 @@ loop.webapp = (function($, _, OT, webL10
      * server.
      */
     setupOutgoingCall: function() {
       var loopToken = this._conversation.get("loopToken");
       if (!loopToken) {
         this._notifier.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
+        var callType = this._conversation.get("selectedCallType");
+
         this._conversation.once("call:outgoing", this.startCall, this);
 
-        // XXX For now, we assume both audio and video as there is no
-        // other option to select (bug 1048333)
-        this._client.requestCallInfo(this._conversation.get("loopToken"), "audio-video",
-                                     function(err, sessionData) {
+        this._client.requestCallInfo(this._conversation.get("loopToken"),
+                                     callType, function(err, sessionData) {
           if (err) {
             switch (err.errno) {
               // loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is
               // missing OR expired; we treat this information as if the url is always
               // expired.
               case 105:
                 this._onSessionExpired();
                 break;
@@ -384,17 +443,18 @@ loop.webapp = (function($, _, OT, webL10
      */
     loadConversation: function(loopToken) {
       if (!this._conversation.isSessionReady()) {
         // User has loaded this url directly, actually setup the call.
         return this.navigate("call/" + loopToken, {trigger: true});
       }
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
-        model: this._conversation
+        model: this._conversation,
+        video: {enabled: this._conversation.hasVideoStream("outgoing")}
       }));
     }
   });
 
   /**
    * Local helpers.
    */
   function WebappHelper() {
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -139,76 +139,109 @@ loop.webapp = (function($, _, OT, webL10
      * - {loop.shared.model.ConversationModel}    model    Conversation model.
      * - {loop.shared.views.NotificationListView} notifier Notifier component.
      *
      */
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false
+        disableCallButton: false,
+        showCallOptionsMenu: false
       };
     },
 
     propTypes: {
       model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                                        .isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifier: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired
     },
 
     componentDidMount: function() {
+      // Listen for events & hide dropdown menu if user clicks away
+      window.addEventListener("click", this.clickHandler);
       this.props.model.listenTo(this.props.model, "session:error",
                                 this._onSessionError);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
       // XXX DOM element does not exist before React view gets instantiated
       // We should turn the notifier into a react component
       this.props.notifier.$el = $("#messages");
     },
 
     _onSessionError: function(error) {
       console.error(error);
       this.props.notifier.errorL10n("unable_retrieve_call_info");
     },
 
     /**
      * Initiates the call.
+     * Takes in a call type parameter "audio" or "audio-video" and returns
+     * a function that initiates the call. React click handler requires a function
+     * to be called when that event happenes.
+     *
+     * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function() {
-      this.setState({disableCallButton: true});
-      this.props.model.setupOutgoingCall();
+    _initiateOutgoingCall: function(callType) {
+      return function() {
+        this.props.model.set("selectedCallType", callType);
+        this.setState({disableCallButton: true});
+        this.props.model.setupOutgoingCall();
+      }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifier.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
 
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
+    componentWillUnmount: function() {
+      window.removeEventListener("click", this.clickHandler);
+    },
+
+    clickHandler: function(e) {
+      if (!e.target.classList.contains('btn-chevron') &&
+          this.state.showCallOptionsMenu) {
+            this._toggleCallOptionsMenu();
+      }
+    },
+
+    _toggleCallOptionsMenu: function() {
+      var state = this.state.showCallOptionsMenu;
+      this.setState({showCallOptionsMenu: !state});
+    },
+
     render: function() {
       var tos_link_name = __("terms_of_use_link_text");
       var privacy_notice_name = __("privacy_notice_link_text");
 
       var tosHTML = __("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='" +
           "https://accounts.firefox.com/legal/terms'>" + tos_link_name + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
           "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
       });
 
-      var callButtonClasses = "btn btn-success btn-large " +
+      var btnClassStartCall = "btn btn-large btn-success " +
+                              "start-audio-video-call " +
                               loop.shared.utils.getTargetPlatform();
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showCallOptionsMenu
+      });
 
       return (
         /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
@@ -216,22 +249,48 @@ loop.webapp = (function($, _, OT, webL10
             <p className="large-font light-weight-font">
               {__("initiate_call_button_label")}
             </p>
 
             <div id="messages"></div>
 
             <div className="button-group">
               <div className="flex-padding-1"></div>
-              <button ref="submitButton" onClick={this._initiateOutgoingCall}
-                className={callButtonClasses}
-                disabled={this.state.disableCallButton}>
-                {__("initiate_call_button")}
-                <i className="icon icon-video"></i>
-              </button>
+              <div className="button-chevron-menu-group">
+                <div className="button-group-chevron">
+                  <div className="button-group">
+
+                    <button className={btnClassStartCall}
+                            onClick={this._initiateOutgoingCall("audio-video")}
+                            disabled={this.state.disableCallButton}
+                            title={__("initiate_audio_video_call_tooltip")} >
+                      {__("initiate_audio_video_call_button")}
+                    </button>
+
+                    <div className="btn-chevron"
+                      onClick={this._toggleCallOptionsMenu}>
+                    </div>
+
+                  </div>
+
+                  <ul className={dropdownMenuClasses}>
+                    <li>
+                      {/*
+                       Button required for disabled state.
+                       */}
+                      <button className="start-audio-only-call"
+                              onClick={this._initiateOutgoingCall("audio")}
+                              disabled={this.state.disableCallButton} >
+                        {__("initiate_audio_call_button")}
+                      </button>
+                    </li>
+                  </ul>
+
+                </div>
+              </div>
               <div className="flex-padding-1"></div>
             </div>
 
             <p className="terms-service"
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
           <ConversationFooter />
@@ -275,22 +334,22 @@ loop.webapp = (function($, _, OT, webL10
      * server.
      */
     setupOutgoingCall: function() {
       var loopToken = this._conversation.get("loopToken");
       if (!loopToken) {
         this._notifier.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
+        var callType = this._conversation.get("selectedCallType");
+
         this._conversation.once("call:outgoing", this.startCall, this);
 
-        // XXX For now, we assume both audio and video as there is no
-        // other option to select (bug 1048333)
-        this._client.requestCallInfo(this._conversation.get("loopToken"), "audio-video",
-                                     function(err, sessionData) {
+        this._client.requestCallInfo(this._conversation.get("loopToken"),
+                                     callType, function(err, sessionData) {
           if (err) {
             switch (err.errno) {
               // loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is
               // missing OR expired; we treat this information as if the url is always
               // expired.
               case 105:
                 this._onSessionExpired();
                 break;
@@ -384,17 +443,18 @@ loop.webapp = (function($, _, OT, webL10
      */
     loadConversation: function(loopToken) {
       if (!this._conversation.isSessionReady()) {
         // User has loaded this url directly, actually setup the call.
         return this.navigate("call/" + loopToken, {trigger: true});
       }
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
-        model: this._conversation
+        model: this._conversation,
+        video: {enabled: this._conversation.hasVideoStream("outgoing")}
       }));
     }
   });
 
   /**
    * Local helpers.
    */
   function WebappHelper() {
--- a/browser/components/loop/standalone/content/l10n/data.ini
+++ b/browser/components/loop/standalone/content/l10n/data.ini
@@ -20,17 +20,19 @@ sorry_device_unsupported=Sorry, Loop doe
 use_firefox_windows_mac_linux=Please open this page using the latest Firefox on Windows, Android, Mac or Linux.
 connection_error_see_console_notification=Call failed; see console for details.
 call_url_unavailable_notification_heading=Oops!
 call_url_unavailable_notification_message=This URL is unavailable.
 promote_firefox_hello_heading=Download Firefox to make free audio and video calls!
 get_firefox_button=Get Firefox
 call_url_unavailable_notification=This URL is unavailable.
 initiate_call_button_label=Click Call to start a video chat
-initiate_call_button=Call
+initiate_audio_video_call_button=Call
+initiate_audio_video_call_tooltip=Start a video call
+initiate_audio_call_button=Voice call
 ## LOCALIZATION NOTE (legal_text_and_links): In this item, don't translate the
 ## part between {{..}}
 legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}
 terms_of_use_link_text=Terms of use
 privacy_notice_link_text=Privacy notice
 brandShortname=Firefox
 clientShortname=WebRTC!
 ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -114,17 +114,18 @@ describe("loop.conversation", function()
 
     beforeEach(function() {
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {},
         pendingCallTimeout: 1000,
       });
       sandbox.stub(client, "requestCallsInfo");
-      sandbox.stub(conversation, "setSessionData");
+      sandbox.stub(conversation, "setIncomingSessionData");
+      sandbox.stub(conversation, "setOutgoingSessionData");
     });
 
     describe("Routes", function() {
       var router;
 
       beforeEach(function() {
         router = new ConversationRouter({
           client: client,
@@ -187,36 +188,51 @@ describe("loop.conversation", function()
 
         describe("requestCallsInfo successful", function() {
           var fakeSessionData;
 
           beforeEach(function() {
             fakeSessionData  = {
               sessionId:    "sessionId",
               sessionToken: "sessionToken",
-              apiKey:       "apiKey"
+              apiKey:       "apiKey",
+              callType:     "callType"
             };
 
             client.requestCallsInfo.callsArgWith(1, null, [fakeSessionData]);
           });
 
           it("should store the session data", function() {
             router.incoming(42);
 
-            sinon.assert.calledOnce(conversation.setSessionData);
-            sinon.assert.calledWithExactly(conversation.setSessionData,
+            sinon.assert.calledOnce(conversation.setIncomingSessionData);
+            sinon.assert.calledWithExactly(conversation.setIncomingSessionData,
                                            fakeSessionData);
           });
 
+          it("should call the view with video.enabled=false", function() {
+            sandbox.stub(conversation, "get").withArgs("callType").returns("audio");
+            router.incoming("fakeVersion");
+
+            sinon.assert.calledOnce(conversation.get);
+            sinon.assert.calledOnce(loop.conversation.IncomingCallView);
+            sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
+                                           {model: conversation,
+                                           video: {enabled: false}});
+          });
+
           it("should display the incoming call view", function() {
+            sandbox.stub(conversation, "get").withArgs("callType")
+                                                      .returns("audio-video");
             router.incoming("fakeVersion");
 
             sinon.assert.calledOnce(loop.conversation.IncomingCallView);
             sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
-                                           {model: conversation});
+                                           {model: conversation,
+                                           video: {enabled: true}});
             sinon.assert.calledOnce(router.loadReactComponent);
             sinon.assert.calledWith(router.loadReactComponent,
               sinon.match(function(value) {
                 return TestUtils.isDescriptorOfType(value,
                   loop.conversation.IncomingCallView);
               }));
           });
         });
@@ -434,19 +450,42 @@ describe("loop.conversation", function()
     });
 
     describe("click event on .btn-accept", function() {
       it("should trigger an 'accept' conversation model event", function() {
         var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
 
         TestUtils.Simulate.click(buttonAccept);
 
-        sinon.assert.calledOnce(model.trigger);
+        /* Setting a model property triggers 2 events */
+        sinon.assert.calledThrice(model.trigger);
         sinon.assert.calledWith(model.trigger, "accept");
-        });
+        sinon.assert.calledWith(model.trigger, "change:selectedCallType");
+        sinon.assert.calledWith(model.trigger, "change");
+      });
+
+      it("should set selectedCallType to audio-video", function() {
+        var buttonAccept = view.getDOMNode().querySelector(".call-audio-video");
+        sandbox.stub(model, "set");
+
+        TestUtils.Simulate.click(buttonAccept);
+
+        sinon.assert.calledOnce(model.set);
+        sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
+      });
+
+      it("should set selectedCallType to audio", function() {
+        var buttonAccept = view.getDOMNode().querySelector(".call-audio-only");
+        sandbox.stub(model, "set");
+
+        TestUtils.Simulate.click(buttonAccept);
+
+        sinon.assert.calledOnce(model.set);
+        sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
+      });
     });
 
     describe("click event on .btn-decline", function() {
       it("should trigger an 'decline' conversation model event", function() {
         var buttonDecline = view.getDOMNode().querySelector(".btn-decline");
 
         TestUtils.Simulate.click(buttonDecline);
 
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -19,17 +19,18 @@ describe("loop.shared.models", function(
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function(xhr) {
       requests.push(xhr);
     };
     fakeSessionData = {
       sessionId:    "sessionId",
       sessionToken: "sessionToken",
-      apiKey:       "apiKey"
+      apiKey:       "apiKey",
+      callType:     "callType"
     };
     fakeSession = _.extend({
       connect: function () {},
       endSession: sandbox.stub(),
       set: sandbox.stub(),
       disconnect: sandbox.spy(),
       unpublish: sandbox.spy()
     }, Backbone.Events);
@@ -96,23 +97,24 @@ describe("loop.shared.models", function(
 
           conversation.setupOutgoingCall();
         });
       });
 
       describe("#outgoing", function() {
         beforeEach(function() {
           sandbox.stub(conversation, "endSession");
-          sandbox.stub(conversation, "setSessionData");
+          sandbox.stub(conversation, "setOutgoingSessionData");
+          sandbox.stub(conversation, "setIncomingSessionData");
         });
 
-        it("should save the sessionData", function() {
+        it("should save the outgoing sessionData", function() {
           conversation.outgoing(fakeSessionData);
 
-          sinon.assert.calledOnce(conversation.setSessionData);
+          sinon.assert.calledOnce(conversation.setOutgoingSessionData);
         });
 
         it("should trigger a `call:outgoing` event", function(done) {
           conversation.once("call:outgoing", function() {
             done();
           });
 
           conversation.outgoing();
@@ -134,23 +136,34 @@ describe("loop.shared.models", function(
 
             conversation.outgoing();
 
             sandbox.clock.tick(1001);
           });
       });
 
       describe("#setSessionData", function() {
-        it("should update conversation session information", function() {
-          conversation.setSessionData(fakeSessionData);
+        it("should update outgoing conversation session information",
+           function() {
+             conversation.setOutgoingSessionData(fakeSessionData);
+
+             expect(conversation.get("sessionId")).eql("sessionId");
+             expect(conversation.get("sessionToken")).eql("sessionToken");
+             expect(conversation.get("apiKey")).eql("apiKey");
+           });
 
-          expect(conversation.get("sessionId")).eql("sessionId");
-          expect(conversation.get("sessionToken")).eql("sessionToken");
-          expect(conversation.get("apiKey")).eql("apiKey");
-        });
+        it("should update incoming conversation session information",
+           function() {
+             conversation.setIncomingSessionData(fakeSessionData);
+
+             expect(conversation.get("sessionId")).eql("sessionId");
+             expect(conversation.get("sessionToken")).eql("sessionToken");
+             expect(conversation.get("apiKey")).eql("apiKey");
+             expect(conversation.get("callType")).eql("callType");
+           });
       });
 
       describe("#startSession", function() {
         var model;
 
         beforeEach(function() {
           sandbox.stub(sharedModels.ConversationModel.prototype,
                        "_clearPendingCallTimer");
@@ -354,11 +367,35 @@ describe("loop.shared.models", function(
             sandbox.stub(model, "stopListening");
 
             model.endSession();
             model.trigger("session:ended");
 
             sinon.assert.calledOnce(model.stopListening);
           });
       });
+
+      describe("#hasVideoStream", function() {
+        var model;
+
+        beforeEach(function() {
+          model = new sharedModels.ConversationModel(fakeSessionData, {
+            sdk: fakeSDK,
+            pendingCallTimeout: 1000
+          });
+          model.startSession();
+        });
+
+        it("should return true for incoming callType", function() {
+          model.set("callType", "audio-video");
+
+          expect(model.hasVideoStream("incoming")).to.eql(true);
+        });
+
+        it("should return true for outgoing callType", function() {
+          model.set("selectedCallType", "audio-video");
+
+          expect(model.hasVideoStream("outgoing")).to.eql(true);
+        });
+      });
     });
   });
 });
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -207,27 +207,47 @@ describe("loop.shared.views", function()
         pendingCallTimeout: 1000
       });
     });
 
     describe("#componentDidMount", function() {
       it("should start a session", function() {
         sandbox.stub(model, "startSession");
 
-        mountTestComponent({sdk: fakeSDK, model: model});
+        mountTestComponent({
+          sdk: fakeSDK,
+          model: model,
+          video: {enabled: true}
+        });
 
         sinon.assert.calledOnce(model.startSession);
       });
+
+      it("should set the correct stream publish options", function() {
+
+        var component = mountTestComponent({
+          sdk: fakeSDK,
+          model: model,
+          video: {enabled: false}
+        });
+
+        expect(component.publisherConfig.publishVideo).to.eql(false);
+
+      });
     });
 
     describe("constructed", function() {
       var comp;
 
       beforeEach(function() {
-        comp = mountTestComponent({sdk: fakeSDK, model: model});
+        comp = mountTestComponent({
+          sdk: fakeSDK,
+          model: model,
+          video: {enabled: false}
+        });
       });
 
       describe("#hangup", function() {
         beforeEach(function() {
           comp.startPublishing();
         });
 
         it("should disconnect the session", function() {
@@ -288,17 +308,21 @@ describe("loop.shared.views", function()
             sinon.assert.calledOnce(fakePublisher.off);
           });
       });
 
       describe("#publishStream", function() {
         var comp;
 
         beforeEach(function() {
-          comp = mountTestComponent({sdk: fakeSDK, model: model});
+          comp = mountTestComponent({
+            sdk: fakeSDK,
+            model: model,
+            video: {enabled: false}
+          });
           comp.startPublishing();
         });
 
         it("should start streaming local audio", function() {
           comp.publishStream("audio", true);
 
           sinon.assert.calledOnce(fakePublisher.publishAudio);
           sinon.assert.calledWithExactly(fakePublisher.publishAudio, true);
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -297,16 +297,17 @@ describe("loop.webapp", function() {
 
             sinon.assert.calledOnce(notifier.errorL10n);
           });
         });
 
         describe("Has loop token", function() {
           beforeEach(function() {
             conversation.set("loopToken", "fakeToken");
+            conversation.set("selectedCallType", "audio-video");
             sandbox.stub(conversation, "outgoing");
           });
 
           it("should call requestCallInfo on the client",
             function() {
               conversation.setupOutgoingCall();
 
               sinon.assert.calledOnce(client.requestCallInfo);
@@ -395,30 +396,68 @@ describe("loop.webapp", function() {
               model: conversation,
               notifier: notifier,
               client: standaloneClientStub
             })
         );
       });
 
       it("should start the conversation establishment process", function() {
-        var button = view.getDOMNode().querySelector("button");
+        var button = view.getDOMNode().querySelector(".start-audio-video-call");
+        React.addons.TestUtils.Simulate.click(button);
+
+        sinon.assert.calledOnce(setupOutgoingCall);
+        sinon.assert.calledWithExactly(setupOutgoingCall);
+      });
+
+      it("should start the conversation establishment process", function() {
+        var button = view.getDOMNode().querySelector(".start-audio-only-call");
         React.addons.TestUtils.Simulate.click(button);
 
         sinon.assert.calledOnce(setupOutgoingCall);
+        sinon.assert.calledWithExactly(setupOutgoingCall);
       });
 
-      it("should disable current form once session is initiated", function() {
-        conversation.set("loopToken", "fake");
+      it("should disable audio-video button once session is initiated",
+         function() {
+           conversation.set("loopToken", "fake");
+
+           var button = view.getDOMNode().querySelector(".start-audio-video-call");
+           React.addons.TestUtils.Simulate.click(button);
+
+           expect(button.disabled).to.eql(true);
+         });
+
+      it("should disable audio-only button once session is initiated",
+         function() {
+           conversation.set("loopToken", "fake");
+
+           var button = view.getDOMNode().querySelector(".start-audio-only-call");
+           React.addons.TestUtils.Simulate.click(button);
 
-        var button = view.getDOMNode().querySelector("button");
-        React.addons.TestUtils.Simulate.click(button);
+           expect(button.disabled).to.eql(true);
+         });
+
+         it("should set selectedCallType to audio", function() {
+           conversation.set("loopToken", "fake");
+
+           var button = view.getDOMNode().querySelector(".start-audio-only-call");
+           React.addons.TestUtils.Simulate.click(button);
 
-        expect(button.disabled).to.eql(true);
-      });
+           expect(conversation.get("selectedCallType")).to.eql("audio");
+         });
+
+         it("should set selectedCallType to audio-video", function() {
+           conversation.set("loopToken", "fake");
+
+           var button = view.getDOMNode().querySelector(".start-audio-video-call");
+           React.addons.TestUtils.Simulate.click(button);
+
+           expect(conversation.get("selectedCallType")).to.eql("audio-video");
+         });
 
       it("should set state.urlCreationDateString to a locale date string",
          function() {
         // wrap in a jquery object because text is broken up
         // into several span elements
         var date = new Date(0);
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -12,16 +12,17 @@ display_name_available_status=Available
 
 unable_retrieve_url=Sorry, we were unable to retrieve a call url.
 
 # Conversation Window Strings
 
 incoming_call_title=Incoming Call…
 incoming_call=Incoming call
 incoming_call_answer_button=Answer
+incoming_call_answer_audio_only_tooltip=Answer with voice
 incoming_call_decline_button=Decline
 incoming_call_decline_and_block_button=Decline and Block
 incoming_call_block_button=Block
 hangup_button_title=Hangup
 mute_local_audio_button_title=Mute your audio
 unmute_local_audio_button_title=Unmute your audio
 mute_local_video_button_title=Mute your video
 unmute_local_video_button_title=Unmute your video