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 199854 55b8364d966e746a35b67cef3512fed1866e8f79
parent 199853 e99b11f8b3f6e55e8e2daf574686ef9c6b18973f
child 199855 d18d3e14ac3ebce527241cdc585c1f92aed1e818
push id47750
push userryanvm@gmail.com
push dateFri, 15 Aug 2014 21:04:12 +0000
treeherdermozilla-inbound@baea646f5a80 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs990678
milestone34.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 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 0000000000000000000000000000000000000000..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 0000000000000000000000000000000000000000..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