--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -361,22 +361,25 @@ loop.shared.models = (function(l10n) {
*
* @return {String} message
*/
error: function(message) {
this.add({level: "error", message: message});
},
/**
- * Adds a l10n rror notification to the stack and renders it.
+ * Adds a l10n error notification to the stack and renders it.
*
* @param {String} messageId L10n message id
+ * @param {Object} [l10nProps] An object with variables to be interpolated
+ * into the translation. All members' values must be
+ * strings or numbers.
*/
- errorL10n: function(messageId) {
- this.error(l10n.get(messageId));
+ errorL10n: function(messageId, l10nProps) {
+ this.error(l10n.get(messageId, l10nProps));
}
});
return {
ConversationModel: ConversationModel,
NotificationCollection: NotificationCollection,
NotificationModel: NotificationModel
};
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -53,16 +53,25 @@ loop.shared.utils = (function() {
this._iOSRegex = /^(iPad|iPhone|iPod)/;
}
Helper.prototype = {
isFirefox: function(platform) {
return platform.indexOf("Firefox") !== -1;
},
+ isFirefoxOS: function(platform) {
+ // So far WebActivities are exposed only in FxOS, but they may be
+ // exposed in Firefox Desktop soon, so we check for its existence
+ // and also check if the UA belongs to a mobile platform.
+ // XXX WebActivities are also exposed in WebRT on Firefox for Android,
+ // so we need a better check. Bug 1065403.
+ return !!window.MozActivity && /mobi/i.test(platform);
+ },
+
isIOS: function(platform) {
return this._iOSRegex.test(platform);
},
locationHash: function() {
return window.location.hash;
}
};
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -68,8 +68,11 @@ remove_old_config:
# working with that deployment.
.PHONY: config
config:
@echo "var loop = loop || {};" > content/config.js
@echo "loop.config = loop.config || {};" >> content/config.js
@echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
@echo "loop.config.feedbackApiUrl = '`echo $(LOOP_FEEDBACK_API_URL)`';" >> content/config.js
@echo "loop.config.feedbackProductName = '`echo $(LOOP_FEEDBACK_PRODUCT_NAME)`';" >> content/config.js
+ @echo "loop.config.fxosApp = loop.config.fxosApp || {};" >> content/config.js
+ @echo "loop.config.fxosApp.name = 'Loop';" >> content/config.js
+ @echo "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';" >> content/config.js
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1,15 +1,15 @@
/** @jsx React.DOM */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
-/* global loop:true, React */
+/* global loop:true, React, MozActivity */
/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.webapp = (function($, _, OT, mozL10n) {
"use strict";
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
@@ -117,16 +117,105 @@ loop.webapp = (function($, _, OT, mozL10
return (
React.DOM.h1({className: "standalone-header-title"},
React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
)
);
}
});
+ /**
+ * The Firefox Marketplace exposes a web page that contains a postMesssage
+ * based API that wraps a small set of functionality from the WebApps API
+ * that allow us to request the installation of apps given their manifest
+ * URL. We will be embedding the content of this web page within an hidden
+ * iframe in case that we need to request the installation of the FxOS Loop
+ * client.
+ */
+ var FxOSHiddenMarketplace = React.createClass({displayName: 'FxOSHiddenMarketplace',
+ render: function() {
+ return React.DOM.iframe({id: "marketplace", src: this.props.marketplaceSrc, hidden: true});
+ },
+
+ componentDidUpdate: function() {
+ // This happens only once when we change the 'src' property of the iframe.
+ if (this.props.onMarketplaceMessage) {
+ // The reason for listening on the global window instead of on the
+ // iframe content window is because the Marketplace is doing a
+ // window.top.postMessage.
+ window.addEventListener("message", this.props.onMarketplaceMessage);
+ }
+ }
+ });
+
+ var FxOSConversationModel = Backbone.Model.extend({
+ setupOutgoingCall: function() {
+ // The FxOS Loop client exposes a "loop-call" activity. If we get the
+ // activity onerror callback it means that there is no "loop-call"
+ // activity handler available and so no FxOS Loop client installed.
+ var request = new MozActivity({
+ name: "loop-call",
+ data: {
+ type: "loop/token",
+ token: this.get("loopToken"),
+ callerId: this.get("callerId"),
+ callType: this.get("callType")
+ }
+ });
+
+ request.onsuccess = function() {};
+
+ request.onerror = (function(event) {
+ if (event.target.error.name !== "NO_PROVIDER") {
+ console.error ("Unexpected " + event.target.error.name);
+ this.trigger("session:error", "fxos_app_needed", {
+ fxosAppName: loop.config.fxosApp.name
+ });
+ return;
+ }
+ this.trigger("fxos:app-needed");
+ }).bind(this);
+ },
+
+ onMarketplaceMessage: function(event) {
+ var message = event.data;
+ switch (message.name) {
+ case "loaded":
+ var marketplace = window.document.getElementById("marketplace");
+ // Once we have it loaded, we request the installation of the FxOS
+ // Loop client app. We will be receiving the result of this action
+ // via postMessage from the child iframe.
+ marketplace.contentWindow.postMessage({
+ "name": "install-package",
+ "data": {
+ "product": {
+ "name": loop.config.fxosApp.name,
+ "manifest_url": loop.config.fxosApp.manifestUrl,
+ "is_packaged": true
+ }
+ }
+ }, "*");
+ break;
+ case "install-package":
+ window.removeEventListener("message", this.onMarketplaceMessage);
+ if (message.error) {
+ console.error(message.error.error);
+ this.trigger("session:error", "fxos_app_needed", {
+ fxosAppName: loop.config.fxosApp.name
+ });
+ return;
+ }
+ // We installed the FxOS app \o/, so we can continue with the call
+ // process.
+ this.setupOutgoingCall();
+ break;
+ }
+ }
+ });
+
var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
render: function() {
var cx = React.addons.classSet;
var conversationUrl = location.href;
var urlCreationDateClasses = cx({
"light-color-font": true,
"call-url-date": true, /* Used as a handler in the tests */
@@ -228,18 +317,20 @@ loop.webapp = (function($, _, OT, mozL10
* as a `model` property.
*
* Required properties:
* - {loop.shared.models.ConversationModel} model Conversation model.
* - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({displayName: 'StartConversationView',
propTypes: {
- model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ model: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
},
getDefaultProps: function() {
return {showCallOptionsMenu: false};
},
@@ -252,25 +343,39 @@ loop.webapp = (function($, _, OT, mozL10
};
},
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.model.listenTo(this.props.model, "fxos:app-needed",
+ this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
},
- _onSessionError: function(error) {
- console.error(error);
- this.props.notifications.errorL10n("unable_retrieve_call_info");
+ _onSessionError: function(error, l10nProps) {
+ var errorL10n = error || "unable_retrieve_call_info";
+ this.props.notifications.errorL10n(errorL10n, l10nProps);
+ console.error(errorL10n);
},
+ _onFxOSAppNeeded: function() {
+ this.setState({
+ marketplaceSrc: loop.config.marketplaceUrl
+ });
+ this.setState({
+ onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
+ this.props.model
+ )
+ });
+ },
+
/**
* 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"
*/
@@ -325,16 +430,20 @@ loop.webapp = (function($, _, OT, mozL10
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
+ var chevronClasses = React.addons.classSet({
+ "btn-chevron": true,
+ "disabled": this.state.disableCallButton
+ });
return (
React.DOM.div({className: "container"},
React.DOM.div({className: "container-box"},
ConversationHeader({
urlCreationDateString: this.state.urlCreationDateString}),
@@ -355,17 +464,17 @@ loop.webapp = (function($, _, OT, mozL10
disabled: this.state.disableCallButton,
title: mozL10n.get("initiate_audio_video_call_tooltip2")},
React.DOM.span({className: "standalone-call-btn-text"},
mozL10n.get("initiate_audio_video_call_button2")
),
React.DOM.span({className: "standalone-call-btn-video-icon"})
),
- React.DOM.div({className: "btn-chevron",
+ React.DOM.div({className: chevronClasses,
onClick: this._toggleCallOptionsMenu}
)
),
React.DOM.ul({className: dropdownMenuClasses},
React.DOM.li(null,
/*
@@ -383,16 +492,20 @@ loop.webapp = (function($, _, OT, mozL10
),
React.DOM.div({className: "flex-padding-1"})
),
React.DOM.p({className: tosClasses,
dangerouslySetInnerHTML: {__html: tosHTML}})
),
+ FxOSHiddenMarketplace({
+ marketplaceSrc: this.state.marketplaceSrc,
+ onMarketplaceMessage: this.state.onMarketplaceMessage}),
+
ConversationFooter(null)
)
);
}
});
/**
* Ended conversation view.
@@ -429,18 +542,20 @@ loop.webapp = (function($, _, OT, mozL10
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
*
* At the moment, it does more than that, these parts need refactoring out.
*/
var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
- conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ conversation: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
@@ -684,18 +799,20 @@ loop.webapp = (function($, _, OT, mozL10
/**
* Webapp Root View. This is the main, single, view that controls the display
* of the webapp page.
*/
var WebappRootView = React.createClass({displayName: 'WebappRootView',
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
- conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ conversation: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
@@ -731,19 +848,25 @@ loop.webapp = (function($, _, OT, mozL10
* App initialization.
*/
function init() {
var helper = new sharedUtils.Helper();
var client = new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
});
var notifications = new sharedModels.NotificationCollection();
- var conversation = new sharedModels.ConversationModel({}, {
- sdk: OT
- });
+ var conversation
+ if (helper.isFirefoxOS(navigator.userAgent)) {
+ conversation = new FxOSConversationModel();
+ } else {
+ conversation = new sharedModels.ConversationModel({}, {
+ sdk: OT
+ });
+ }
+
var feedbackApiClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
user_agent: navigator.userAgent,
url: document.location.origin
});
// Obtain the loopToken and pass it to the conversation
@@ -772,11 +895,12 @@ loop.webapp = (function($, _, OT, mozL10
StartConversationView: StartConversationView,
OutgoingConversationView: OutgoingConversationView,
EndedConversationView: EndedConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,
UnsupportedDeviceView: UnsupportedDeviceView,
init: init,
PromoteFirefoxView: PromoteFirefoxView,
- WebappRootView: WebappRootView
+ WebappRootView: WebappRootView,
+ FxOSConversationModel: FxOSConversationModel
};
})(jQuery, _, window.OT, navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1,15 +1,15 @@
/** @jsx React.DOM */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
-/* global loop:true, React */
+/* global loop:true, React, MozActivity */
/* jshint newcap:false, maxlen:false */
var loop = loop || {};
loop.webapp = (function($, _, OT, mozL10n) {
"use strict";
loop.config = loop.config || {};
loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
@@ -117,16 +117,105 @@ loop.webapp = (function($, _, OT, mozL10
return (
<h1 className="standalone-header-title">
<strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
</h1>
);
}
});
+ /**
+ * The Firefox Marketplace exposes a web page that contains a postMesssage
+ * based API that wraps a small set of functionality from the WebApps API
+ * that allow us to request the installation of apps given their manifest
+ * URL. We will be embedding the content of this web page within an hidden
+ * iframe in case that we need to request the installation of the FxOS Loop
+ * client.
+ */
+ var FxOSHiddenMarketplace = React.createClass({
+ render: function() {
+ return <iframe id="marketplace" src={this.props.marketplaceSrc} hidden/>;
+ },
+
+ componentDidUpdate: function() {
+ // This happens only once when we change the 'src' property of the iframe.
+ if (this.props.onMarketplaceMessage) {
+ // The reason for listening on the global window instead of on the
+ // iframe content window is because the Marketplace is doing a
+ // window.top.postMessage.
+ window.addEventListener("message", this.props.onMarketplaceMessage);
+ }
+ }
+ });
+
+ var FxOSConversationModel = Backbone.Model.extend({
+ setupOutgoingCall: function() {
+ // The FxOS Loop client exposes a "loop-call" activity. If we get the
+ // activity onerror callback it means that there is no "loop-call"
+ // activity handler available and so no FxOS Loop client installed.
+ var request = new MozActivity({
+ name: "loop-call",
+ data: {
+ type: "loop/token",
+ token: this.get("loopToken"),
+ callerId: this.get("callerId"),
+ callType: this.get("callType")
+ }
+ });
+
+ request.onsuccess = function() {};
+
+ request.onerror = (function(event) {
+ if (event.target.error.name !== "NO_PROVIDER") {
+ console.error ("Unexpected " + event.target.error.name);
+ this.trigger("session:error", "fxos_app_needed", {
+ fxosAppName: loop.config.fxosApp.name
+ });
+ return;
+ }
+ this.trigger("fxos:app-needed");
+ }).bind(this);
+ },
+
+ onMarketplaceMessage: function(event) {
+ var message = event.data;
+ switch (message.name) {
+ case "loaded":
+ var marketplace = window.document.getElementById("marketplace");
+ // Once we have it loaded, we request the installation of the FxOS
+ // Loop client app. We will be receiving the result of this action
+ // via postMessage from the child iframe.
+ marketplace.contentWindow.postMessage({
+ "name": "install-package",
+ "data": {
+ "product": {
+ "name": loop.config.fxosApp.name,
+ "manifest_url": loop.config.fxosApp.manifestUrl,
+ "is_packaged": true
+ }
+ }
+ }, "*");
+ break;
+ case "install-package":
+ window.removeEventListener("message", this.onMarketplaceMessage);
+ if (message.error) {
+ console.error(message.error.error);
+ this.trigger("session:error", "fxos_app_needed", {
+ fxosAppName: loop.config.fxosApp.name
+ });
+ return;
+ }
+ // We installed the FxOS app \o/, so we can continue with the call
+ // process.
+ this.setupOutgoingCall();
+ break;
+ }
+ }
+ });
+
var ConversationHeader = React.createClass({
render: function() {
var cx = React.addons.classSet;
var conversationUrl = location.href;
var urlCreationDateClasses = cx({
"light-color-font": true,
"call-url-date": true, /* Used as a handler in the tests */
@@ -228,18 +317,20 @@ loop.webapp = (function($, _, OT, mozL10
* as a `model` property.
*
* Required properties:
* - {loop.shared.models.ConversationModel} model Conversation model.
* - {loop.shared.models.NotificationCollection} notifications
*/
var StartConversationView = React.createClass({
propTypes: {
- model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ model: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
// XXX Check more tightly here when we start injecting window.loop.*
notifications: React.PropTypes.object.isRequired,
client: React.PropTypes.object.isRequired
},
getDefaultProps: function() {
return {showCallOptionsMenu: false};
},
@@ -252,25 +343,39 @@ loop.webapp = (function($, _, OT, mozL10
};
},
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.model.listenTo(this.props.model, "fxos:app-needed",
+ this._onFxOSAppNeeded);
this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
this._setConversationTimestamp);
},
- _onSessionError: function(error) {
- console.error(error);
- this.props.notifications.errorL10n("unable_retrieve_call_info");
+ _onSessionError: function(error, l10nProps) {
+ var errorL10n = error || "unable_retrieve_call_info";
+ this.props.notifications.errorL10n(errorL10n, l10nProps);
+ console.error(errorL10n);
},
+ _onFxOSAppNeeded: function() {
+ this.setState({
+ marketplaceSrc: loop.config.marketplaceUrl
+ });
+ this.setState({
+ onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
+ this.props.model
+ )
+ });
+ },
+
/**
* 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"
*/
@@ -325,16 +430,20 @@ loop.webapp = (function($, _, OT, mozL10
"native-dropdown-large-parent": true,
"standalone-dropdown-menu": true,
"visually-hidden": !this.state.showCallOptionsMenu
});
var tosClasses = React.addons.classSet({
"terms-service": true,
hide: (localStorage.getItem("has-seen-tos") === "true")
});
+ var chevronClasses = React.addons.classSet({
+ "btn-chevron": true,
+ "disabled": this.state.disableCallButton
+ });
return (
<div className="container">
<div className="container-box">
<ConversationHeader
urlCreationDateString={this.state.urlCreationDateString} />
@@ -355,17 +464,17 @@ loop.webapp = (function($, _, OT, mozL10
disabled={this.state.disableCallButton}
title={mozL10n.get("initiate_audio_video_call_tooltip2")} >
<span className="standalone-call-btn-text">
{mozL10n.get("initiate_audio_video_call_button2")}
</span>
<span className="standalone-call-btn-video-icon"></span>
</button>
- <div className="btn-chevron"
+ <div className={chevronClasses}
onClick={this._toggleCallOptionsMenu}>
</div>
</div>
<ul className={dropdownMenuClasses}>
<li>
{/*
@@ -383,16 +492,20 @@ loop.webapp = (function($, _, OT, mozL10
</div>
<div className="flex-padding-1"></div>
</div>
<p className={tosClasses}
dangerouslySetInnerHTML={{__html: tosHTML}}></p>
</div>
+ <FxOSHiddenMarketplace
+ marketplaceSrc={this.state.marketplaceSrc}
+ onMarketplaceMessage= {this.state.onMarketplaceMessage} />
+
<ConversationFooter />
</div>
);
}
});
/**
* Ended conversation view.
@@ -429,18 +542,20 @@ loop.webapp = (function($, _, OT, mozL10
* This view manages the outgoing conversation views - from
* call initiation through to the actual conversation and call end.
*
* At the moment, it does more than that, these parts need refactoring out.
*/
var OutgoingConversationView = React.createClass({
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
- conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ conversation: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
@@ -684,18 +799,20 @@ loop.webapp = (function($, _, OT, mozL10
/**
* Webapp Root View. This is the main, single, view that controls the display
* of the webapp page.
*/
var WebappRootView = React.createClass({
propTypes: {
client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
- conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
- .isRequired,
+ conversation: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(sharedModels.ConversationModel),
+ React.PropTypes.instanceOf(FxOSConversationModel)
+ ]).isRequired,
helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
.isRequired,
sdk: React.PropTypes.object.isRequired,
feedbackApiClient: React.PropTypes.object.isRequired
},
getInitialState: function() {
@@ -731,19 +848,25 @@ loop.webapp = (function($, _, OT, mozL10
* App initialization.
*/
function init() {
var helper = new sharedUtils.Helper();
var client = new loop.StandaloneClient({
baseServerUrl: loop.config.serverUrl
});
var notifications = new sharedModels.NotificationCollection();
- var conversation = new sharedModels.ConversationModel({}, {
- sdk: OT
- });
+ var conversation
+ if (helper.isFirefoxOS(navigator.userAgent)) {
+ conversation = new FxOSConversationModel();
+ } else {
+ conversation = new sharedModels.ConversationModel({}, {
+ sdk: OT
+ });
+ }
+
var feedbackApiClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
user_agent: navigator.userAgent,
url: document.location.origin
});
// Obtain the loopToken and pass it to the conversation
@@ -772,11 +895,12 @@ loop.webapp = (function($, _, OT, mozL10
StartConversationView: StartConversationView,
OutgoingConversationView: OutgoingConversationView,
EndedConversationView: EndedConversationView,
HomeView: HomeView,
UnsupportedBrowserView: UnsupportedBrowserView,
UnsupportedDeviceView: UnsupportedDeviceView,
init: init,
PromoteFirefoxView: PromoteFirefoxView,
- WebappRootView: WebappRootView
+ WebappRootView: WebappRootView,
+ FxOSConversationModel: FxOSConversationModel
};
})(jQuery, _, window.OT, navigator.mozL10n);
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -41,16 +41,17 @@ legal_text_and_links=By using this produ
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)
call_url_creation_date_label=(from {{call_url_creation_date}})
call_progress_connecting_description=Connecting…
call_progress_ringing_description=Ringing…
+fxos_app_needed=Please install the {{fxosAppName}} app from the Firefox Marketplace.
feedback_call_experience_heading2=How was your conversation?
feedback_what_makes_you_sad=What makes you sad?
feedback_thank_you_heading=Thank you for your feedback!
feedback_category_audio_quality=Audio quality
feedback_category_video_quality=Video quality
feedback_category_was_disconnected=Was disconnected
feedback_category_confusing=Confusing
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -16,16 +16,22 @@ function getConfigFile(req, res) {
res.set('Content-Type', 'text/javascript');
res.send([
"var loop = loop || {};",
"loop.config = loop.config || {};",
"loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';",
"loop.config.feedbackApiUrl = '" + feedbackApiUrl + "';",
"loop.config.feedbackProductName = '" + feedbackProductName + "';",
+ // XXX Update with the real marketplace url once the FxOS Loop app is
+ // uploaded to the marketplace bug 1053424
+ "loop.config.marketplaceUrl = 'http://fake-market.herokuapp.com/iframe-install.html'",
+ "loop.config.fxosApp = loop.config.fxosApp || {};",
+ "loop.config.fxosApp.name = 'Loop';",
+ "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';"
].join("\n"));
}
app.get('/content/config.js', getConfigFile);
// This lets /test/ be mapped to the right place for running tests
app.use('/', express.static(__dirname + '/../'));
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -350,18 +350,18 @@ describe("loop.shared.models", function(
});
});
describe("NotificationCollection", function() {
var collection, notifData, testNotif;
beforeEach(function() {
collection = new sharedModels.NotificationCollection();
- sandbox.stub(l10n, "get", function(x) {
- return "translated:" + x;
+ sandbox.stub(l10n, "get", function(x, y) {
+ return "translated:" + x + (y ? ':' + y : '');
});
notifData = {level: "error", message: "plop"};
testNotif = new sharedModels.NotificationModel(notifData);
});
describe("#warn", function() {
it("should add a warning notification to the stack", function() {
collection.warn("watch out");
@@ -395,12 +395,21 @@ describe("loop.shared.models", function(
describe("#errorL10n", function() {
it("should notify an error using a l10n string id", function() {
collection.errorL10n("fakeId");
expect(collection).to.have.length.of(1);
expect(collection.at(0).get("level")).eql("error");
expect(collection.at(0).get("message")).eql("translated:fakeId");
});
+
+ it("should notify an error using a l10n string id + l10n properties",
+ function() {
+ collection.errorL10n("fakeId", "fakeProp");
+
+ expect(collection).to.have.length.of(1);
+ expect(collection.at(0).get("level")).eql("error");
+ expect(collection.at(0).get("message")).eql("translated:fakeId:fakeProp");
+ });
});
});
});
--- a/browser/components/loop/test/shared/utils_test.js
+++ b/browser/components/loop/test/shared/utils_test.js
@@ -48,16 +48,49 @@ describe("loop.shared.utils", function()
expect(helper.isFirefox("Firefox/Gecko")).eql(true);
expect(helper.isFirefox("Gecko/Firefox/Chuck Norris")).eql(true);
});
it("shouldn't detect Firefox with other platforms", function() {
expect(helper.isFirefox("Opera")).eql(false);
});
});
+
+ describe("#isFirefoxOS", function() {
+ describe("without mozActivities", function() {
+ it("shouldn't detect FirefoxOS on mobile platform", function() {
+ expect(helper.isFirefoxOS("mobi")).eql(false);
+ });
+
+ it("shouldn't detect FirefoxOS on non mobile platform", function() {
+ expect(helper.isFirefoxOS("whatever")).eql(false);
+ });
+ });
+
+ describe("with mozActivities", function() {
+ var realMozActivity;
+
+ before(function() {
+ realMozActivity = window.MozActivity;
+ window.MozActivity = {};
+ });
+
+ after(function() {
+ window.MozActivity = realMozActivity;
+ });
+
+ it("should detect FirefoxOS on mobile platform", function() {
+ expect(helper.isFirefoxOS("mobi")).eql(true);
+ });
+
+ it("shouldn't detect FirefoxOS on non mobile platform", function() {
+ expect(helper.isFirefoxOS("whatever")).eql(false);
+ });
+ });
+ });
});
describe("#getBoolPreference", function() {
afterEach(function() {
navigator.mozLoop = undefined;
localStorage.removeItem("test.true");
});
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -15,16 +15,19 @@ describe("loop.shared.views", function()
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views,
getReactElementByClass = TestUtils.findRenderedDOMComponentWithClass,
sandbox;
beforeEach(function() {
sandbox = sinon.sandbox.create();
sandbox.useFakeTimers(); // exposes sandbox.clock as a fake timer
+ sandbox.stub(l10n, "get", function(x) {
+ return "translated:" + x;
+ });
});
afterEach(function() {
$("#fixtures").empty();
sandbox.restore();
});
describe("MediaControlButton", function() {
@@ -419,19 +422,16 @@ describe("loop.shared.views", function()
});
});
});
describe("FeedbackView", function() {
var comp, fakeFeedbackApiClient;
beforeEach(function() {
- sandbox.stub(l10n, "get", function(x) {
- return x;
- });
fakeFeedbackApiClient = {send: sandbox.stub()};
comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
feedbackApiClient: fakeFeedbackApiClient
}));
});
// local test helpers
function clickHappyFace(comp) {
@@ -596,19 +596,16 @@ describe("loop.shared.views", function()
describe("NotificationListView", function() {
var coll, view, testNotif;
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(sharedViews.NotificationListView(props));
}
beforeEach(function() {
- sandbox.stub(l10n, "get", function(x) {
- return "translated:" + x;
- });
coll = new sharedModels.NotificationCollection();
view = mountTestComponent({notifications: coll});
testNotif = {level: "warning", message: "foo"};
sinon.spy(view, "render");
});
afterEach(function() {
view.render.restore();
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -379,17 +379,17 @@ describe("loop.webapp", function() {
describe("subscribedStream", function() {
it("should not notify the websocket if only one stream is up",
function() {
conversation.set("subscribedStream", true);
sinon.assert.notCalled(ocView._websocket.mediaUp);
});
- it("should notify the websocket that media is up if both streams" +
+ it("should notify tloadhe websocket that media is up if both streams" +
"are connected", function() {
conversation.set("publishedStream", true);
conversation.set("subscribedStream", true);
sinon.assert.calledOnce(ocView._websocket.mediaUp);
});
});
});
@@ -686,50 +686,79 @@ describe("loop.webapp", function() {
beforeEach(function() {
conversation = new sharedModels.ConversationModel({
loopToken: "fake"
}, {
sdk: {}
});
- sandbox.spy(conversation, "listenTo");
+ conversation.onMarketplaceMessage = function() {};
+ sandbox.stub(notifications, "errorL10n");
requestCallUrlInfo = sandbox.stub();
view = React.addons.TestUtils.renderIntoDocument(
loop.webapp.StartConversationView({
model: conversation,
notifications: notifications,
client: {requestCallUrlInfo: requestCallUrlInfo}
})
);
+
+ loop.config.marketplaceUrl = "http://market/";
});
it("should call requestCallUrlInfo", function() {
sinon.assert.calledOnce(requestCallUrlInfo);
sinon.assert.calledWithExactly(requestCallUrlInfo,
sinon.match.string,
sinon.match.func);
});
- it("should listen for session:error events", function() {
- sinon.assert.calledOnce(conversation.listenTo);
- sinon.assert.calledWithExactly(conversation.listenTo, conversation,
- "session:error", sinon.match.func);
+ it("should add a notification when a session:error model event is " +
+ " received without an argument", function() {
+ conversation.trigger("session:error");
+
+ sinon.assert.calledOnce(notifications.errorL10n);
+ sinon.assert.calledWithExactly(notifications.errorL10n,
+ sinon.match.string, undefined);
});
- it("should trigger a notication when a session:error model event is " +
- " received", function() {
- sandbox.stub(notifications, "errorL10n");
- conversation.trigger("session:error", "tech error");
+ it("should add a notification with the custom message id when a " +
+ "session:error event is fired with an argument", function() {
+ conversation.trigger("session:error", "tech_error");
sinon.assert.calledOnce(notifications.errorL10n);
sinon.assert.calledWithExactly(notifications.errorL10n,
- "unable_retrieve_call_info");
+ "tech_error", undefined);
+ });
+
+ it("should add a notification with the custom message id when a " +
+ "session:error event is fired with an argument and parameters",
+ function() {
+ conversation.trigger("session:error", "tech_error", {param: "value"});
+
+ sinon.assert.calledOnce(notifications.errorL10n);
+ sinon.assert.calledWithExactly(notifications.errorL10n,
+ "tech_error", { param: "value" });
});
+
+ it("should set marketplace hidden iframe src when fxos:app-needed is " +
+ "triggered", function(done) {
+ var marketplace = view.getDOMNode().querySelector("#marketplace");
+ expect(marketplace.src).to.be.equal("");
+
+ conversation.trigger("fxos:app-needed");
+
+ view.forceUpdate(function() {
+ expect(marketplace.src).to.be.equal(loop.config.marketplaceUrl);
+ done();
+ });
+ });
+
});
describe("#render", function() {
var conversation, view, requestCallUrlInfo, oldLocalStorageValue;
beforeEach(function() {
oldLocalStorageValue = localStorage.getItem("has-seen-tos");
localStorage.removeItem("has-seen-tos");
@@ -821,9 +850,203 @@ describe("loop.webapp", function() {
var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
helper: {isFirefox: function() { return false; }}
}));
expect(comp.getDOMNode().querySelectorAll("h3").length).eql(1);
});
});
});
+
+ describe("Firefox OS", function() {
+ var conversation, client;
+
+ before(function() {
+ client = new loop.StandaloneClient({
+ baseServerUrl: "http://fake.example.com"
+ });
+ sandbox.stub(client, "requestCallInfo");
+ conversation = new sharedModels.ConversationModel({}, {
+ sdk: {},
+ pendingCallTimeout: 1000
+ });
+ });
+
+ describe("Setup call", function() {
+ var conversation, setupOutgoingCall, view, requestCallUrlInfo;
+
+ beforeEach(function() {
+ conversation = new loop.webapp.FxOSConversationModel({
+ loopToken: "fakeToken"
+ });
+ setupOutgoingCall = sandbox.stub(conversation, "setupOutgoingCall");
+
+ var standaloneClientStub = {
+ requestCallUrlInfo: function(token, cb) {
+ cb(null, {urlCreationDate: 0});
+ },
+ settings: {baseServerUrl: loop.webapp.baseServerUrl}
+ };
+
+ view = React.addons.TestUtils.renderIntoDocument(
+ loop.webapp.StartConversationView({
+ model: conversation,
+ notifications: notifications,
+ client: standaloneClientStub
+ })
+ );
+ });
+
+ it("should start the conversation establishment process", function() {
+ var button = view.getDOMNode().querySelector("button");
+ React.addons.TestUtils.Simulate.click(button);
+
+ sinon.assert.calledOnce(setupOutgoingCall);
+ });
+ });
+
+ describe("FxOSConversationModel", function() {
+ var model, realMozActivity;
+
+ before(function() {
+ model = new loop.webapp.FxOSConversationModel({
+ loopToken: "fakeToken",
+ callerId: "callerId",
+ callType: "callType"
+ });
+
+ realMozActivity = window.MozActivity;
+
+ loop.config.fxosApp = {
+ name: "Firefox Hello"
+ };
+ });
+
+ after(function() {
+ window.MozActivity = realMozActivity;
+ });
+
+ describe("setupOutgoingCall", function() {
+ var _activityProps, _onerror, trigger;
+
+ function fireError(errorName) {
+ _onerror({
+ target: {
+ error: {
+ name: errorName
+ }
+ }
+ });
+ }
+
+ before(function() {
+ window.MozActivity = function(activityProps) {
+ _activityProps = activityProps;
+ return {
+ set onerror(callback) {
+ _onerror = callback;
+ }
+ };
+ };
+ });
+
+ after(function() {
+ window.MozActivity = realMozActivity;
+ });
+
+ beforeEach(function() {
+ trigger = sandbox.stub(model, "trigger");
+ });
+
+ afterEach(function() {
+ trigger.restore();
+ });
+
+ it("Activity properties", function() {
+ expect(_activityProps).to.not.exist;
+ model.setupOutgoingCall();
+ expect(_activityProps).to.exist;
+ expect(_activityProps).eql({
+ name: "loop-call",
+ data: {
+ type: "loop/token",
+ token: "fakeToken",
+ callerId: "callerId",
+ callType: "callType"
+ }
+ });
+ });
+
+ it("NO_PROVIDER activity error should trigger fxos:app-needed",
+ function() {
+ sinon.assert.notCalled(trigger);
+ model.setupOutgoingCall();
+ fireError("NO_PROVIDER");
+ sinon.assert.calledOnce(trigger);
+ sinon.assert.calledWithExactly(trigger, "fxos:app-needed");
+ }
+ );
+
+ it("Other activity error should trigger session:error",
+ function() {
+ sinon.assert.notCalled(trigger);
+ model.setupOutgoingCall();
+ fireError("whatever");
+ sinon.assert.calledOnce(trigger);
+ sinon.assert.calledWithExactly(trigger, "session:error",
+ "fxos_app_needed", { fxosAppName: loop.config.fxosApp.name });
+ }
+ );
+ });
+
+ describe("onMarketplaceMessage", function() {
+ var view, setupOutgoingCall, trigger;
+
+ before(function() {
+ view = React.addons.TestUtils.renderIntoDocument(
+ loop.webapp.StartConversationView({
+ model: model,
+ notifications: notifications,
+ client: {requestCallUrlInfo: sandbox.stub()}
+ })
+ );
+ });
+
+ beforeEach(function() {
+ setupOutgoingCall = sandbox.stub(model, "setupOutgoingCall");
+ trigger = sandbox.stub(model, "trigger");
+ });
+
+ afterEach(function() {
+ setupOutgoingCall.restore();
+ trigger.restore();
+ });
+
+ it("We should call trigger a FxOS outgoing call if we get " +
+ "install-package message without error", function() {
+ sinon.assert.notCalled(setupOutgoingCall);
+ model.onMarketplaceMessage({
+ data: {
+ name: "install-package"
+ }
+ });
+ sinon.assert.calledOnce(setupOutgoingCall);
+ });
+
+ it("We should trigger a session:error event if we get " +
+ "install-package message with an error", function() {
+ sinon.assert.notCalled(trigger);
+ sinon.assert.notCalled(setupOutgoingCall);
+ model.onMarketplaceMessage({
+ data: {
+ name: "install-package",
+ error: "error"
+ }
+ });
+ sinon.assert.notCalled(setupOutgoingCall);
+ sinon.assert.calledOnce(trigger);
+ sinon.assert.calledWithExactly(trigger, "session:error",
+ "fxos_app_needed", { fxosAppName: loop.config.fxosApp.name });
+ });
+ });
+ });
+ });
});