[client-app] Updates to feature limiting

This is a squash of a bunch of commits releated to feature limiting.
Includes making the upgrade path seamless by accessing billing through a
WebView and making sure that the feature is enabled after upgrading, or
canceled if the upgrade path is stopped by the user in any way.

Included diffs
---------------
Differential Revision: https://phab.nylas.com/D4078
Differential Revision: https://phab.nylas.com/D4136
Differential Revision: https://phab.nylas.com/D4137
Differential Revision: https://phab.nylas.com/D4143
Differential Revision: https://phab.nylas.com/D4147
Differential Revision: https://phab.nylas.com/D4171
Differential Revision: https://phab.nylas.com/D4173
This commit is contained in:
Evan Morikawa 2017-03-06 14:45:55 -05:00 committed by Halla Moore
parent 965946724b
commit 4904a9ae85
14 changed files with 694 additions and 300 deletions

View file

@ -1,69 +1,8 @@
import React from 'react';
import {shell} from 'electron'
import classnames from 'classnames';
import ReactDOM from 'react-dom';
import {IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import networkErrors from 'chromium-net-errors';
import {Webview} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions';
class InitialLoadingCover extends React.Component {
static propTypes = {
ready: React.PropTypes.bool,
error: React.PropTypes.string,
onTryAgain: React.PropTypes.func,
}
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this._slowTimeout = setTimeout(() => {
this.setState({slow: true});
}, 3500);
}
componentWillUnmount() {
clearTimeout(this._slowTimeout);
this._slowTimeout = null;
}
render() {
const classes = classnames({
'webview-cover': true,
'ready': this.props.ready,
'error': this.props.error,
'slow': this.state.slow,
});
let message = this.props.error;
if (this.props.error) {
message = this.props.error;
} else if (this.state.slow) {
message = "Still trying to reach Nylas…";
} else {
message = ' '
}
return (
<div className={classes}>
<div style={{flex: 1}} />
<RetinaImg
className="spinner"
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
<div className="message">{message}</div>
<div className="btn try-again" onClick={this.props.onTryAgain}>Try Again</div>
<div style={{flex: 1}} />
</div>
);
}
}
export default class AuthenticatePage extends React.Component {
static displayName = "AuthenticatePage";
@ -71,72 +10,12 @@ export default class AuthenticatePage extends React.Component {
accountInfo: React.PropTypes.object,
};
constructor(props) {
super(props);
this.state = {
ready: false,
error: null,
};
}
componentDidMount() {
const webview = ReactDOM.findDOMNode(this.refs.webview);
_src() {
const n1Version = NylasEnv.getVersion();
webview.partition = 'in-memory-only';
webview.src = `${IdentityStore.URLRoot}/onboarding?utm_medium=N1&utm_source=OnboardingPage&N1_version=${n1Version}&client_edition=basic`;
webview.addEventListener('did-start-loading', this.webviewDidStartLoading);
webview.addEventListener('did-get-response-details', this.webviewDidGetResponseDetails);
webview.addEventListener('did-fail-load', this.webviewDidFailLoad);
webview.addEventListener('did-finish-load', this.webviewDidFinishLoad);
webview.addEventListener('console-message', (e) => {
if (/^http.+/i.test(e.message)) { shell.openExternal(e.message) }
console.log('Guest page logged a message:', e.message);
});
return `${IdentityStore.URLRoot}/onboarding?utm_medium=N1&utm_source=OnboardingPage&N1_version=${n1Version}&client_edition=basic`
}
onTryAgain = () => {
const webview = ReactDOM.findDOMNode(this.refs.webview);
webview.reload();
}
webviewDidStartLoading = () => {
this.setState({error: null, webviewLoading: true});
}
webviewDidGetResponseDetails = ({httpResponseCode, originalURL}) => {
if (!originalURL.includes(IdentityStore.URLRoot)) {
// This means that some other secondarily loaded resource (like
// analytics or Linkedin, etc) got a response. We don't care about
// that.
return
}
if (httpResponseCode >= 400) {
const error = `
Could not reach Nylas to sign in. Please try again or contact
support@nylas.com if the issue persists.
(${originalURL}: ${httpResponseCode})
`;
this.setState({ready: false, error: error, webviewLoading: false});
}
};
webviewDidFailLoad = ({errorCode, validatedURL}) => {
// "Operation was aborted" can be fired when we move between pages quickly.
if (errorCode === -3) {
return;
}
const e = networkErrors.createByCode(errorCode);
const error = `Could not reach ${validatedURL}. ${e ? e.message : errorCode}`;
this.setState({ready: false, error: error, webviewLoading: false});
}
webviewDidFinishLoad = () => {
// this is sometimes called right after did-fail-load
if (this.state.error) return;
const webview = ReactDOM.findDOMNode(this.refs.webview);
_onDidFinishLoad = (webview) => {
const receiveUserInfo = `
var a = document.querySelector('#pro-account');
result = a ? a.innerText : null;
@ -158,19 +37,7 @@ export default class AuthenticatePage extends React.Component {
render() {
return (
<div className="page authenticate">
<webview ref="webview" is partition="in-memory-only" />
<div className={`webview-loading-spinner loading-${this.state.webviewLoading}`}>
<RetinaImg
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<InitialLoadingCover
ready={this.state.ready}
error={this.state.error}
onTryAgain={this.onTryAgain}
/>
<Webview src={this._src()} onDidFinishLoad={this._onDidFinishLoad} />
</div>
);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import {Actions, IdentityStore} from 'nylas-exports';
import {OpenIdentityPageButton, RetinaImg} from 'nylas-component-kit';
import {OpenIdentityPageButton, BillingModal, RetinaImg} from 'nylas-component-kit';
import {shell} from 'electron';
class PreferencesIdentity extends React.Component {
@ -8,12 +8,12 @@ class PreferencesIdentity extends React.Component {
constructor() {
super();
this.state = this.getStateFromStores();
this.state = this._getStateFromStores();
}
componentDidMount() {
this.unsubscribe = IdentityStore.listen(() => {
this.setState(this.getStateFromStores());
this.setState(this._getStateFromStores());
});
}
@ -21,25 +21,60 @@ class PreferencesIdentity extends React.Component {
this.unsubscribe();
}
getStateFromStores() {
_getStateFromStores() {
return {
identity: IdentityStore.identity() || {},
};
}
_onUpgrade = () => {
Actions.openModal({
component: (
<BillingModal source="preferences" />
),
height: 575,
width: 412,
})
}
_renderBasic() {
const learnMore = () => shell.openExternal("https://nylas.com/nylas-pro")
return (
<div className="row padded">
<div>
You are using <strong>Nylas Mail Basic</strong>. Upgrade to Nylas Mail Pro to unlock a more powerful email experience.
</div>
<div className="subscription-actions">
<div className="btn btn-emphasis" onClick={this._onUpgrade} style={{verticalAlign: "top"}}>Upgrade to Nylas Mail Pro</div>
<div className="btn minor-width" onClick={learnMore}>Learn More</div>
</div>
</div>
)
}
_renderPro() {
return (
<div className="row padded">
<div>
Thank you for using <strong>Nylas Mail Pro</strong>
</div>
<div className="subscription-actions">
<OpenIdentityPageButton label="Manage Billing" path="/dashboard#billing" source="Preferences Billing" campaign="Dashboard" />
</div>
</div>
)
}
render() {
const {identity} = this.state;
const {firstname, lastname, email} = identity;
const logout = () => Actions.logoutNylasIdentity()
const learnMore = () => shell.openExternal("https://nylas.com/nylas-pro")
return (
<div className="container-identity">
<div className="id-header">
Nylas ID:
</div>
<div className="identity-content-box">
<div className="row info-row">
<div className="logo">
<RetinaImg
@ -52,18 +87,13 @@ class PreferencesIdentity extends React.Component {
<div className="email">{email}</div>
<div className="identity-actions">
<OpenIdentityPageButton label="Account Details" path="/dashboard" source="Preferences" campaign="Dashboard" />
<OpenIdentityPageButton label="Upgrade to Nylas Pro" path="/dashboard?upgrade_to_pro=true" source="Preferences" campaign="Dashboard" />
<div className="btn" onClick={logout}>Sign Out</div>
<div className="btn minor-width" onClick={logout}>Sign Out</div>
</div>
</div>
</div>
<div className="row payment-row">
<div>
You are using Nylas Mail Basic. Upgrade to Nylas Pro to unlock a more powerful email experience.
</div>
<div className="btn" onClick={learnMore}>Learn More about Nylas Pro</div>
</div>
{this.state.identity.has_pro_access ? this._renderPro() : this._renderBasic()}
</div>
</div>
);

View file

@ -3,10 +3,9 @@
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
.container-identity {
width: 50%;
max-width: 887px;
min-width: 530px;
margin: auto;
padding-top: @padding-base-vertical * 2;
.id-header {
color: @text-color-very-subtle;
@ -38,15 +37,27 @@
width: 100%;
}
.payment-row {
flex-direction: column;
align-items: flex-start;
border-top: 1px solid @border-color-primary;
.padded {
display: block;
padding: 20px;
padding-left: 137px;
&>div:first-child {
margin-bottom: @padding-base-vertical * 2;
border-top: 1px solid @border-color-primary;
}
.btn {
width: 180px;
&.minor-width {
width: 120px;
}
text-align: center;
margin-right: @padding-base-horizontal;
margin-bottom: @padding-base-horizontal;
}
.identity-actions {
margin-top: @padding-small-vertical + 1;
}
.subscription-actions {
margin-top: 20px;
}
.info-row {
@ -55,19 +66,11 @@
margin-right: 30px;
}
.identity-info {
flex: 1;
line-height: 1.9em;
.name {
font-size: 1.2em;
}
.identity-actions {
margin-top: @padding-small-vertical + 1;
.btn {
width: 170px;
text-align: center;
margin-right: @padding-base-horizontal;
margin-bottom: @padding-base-horizontal;
}
}
}
}
}

View file

@ -1,7 +1,6 @@
import _ from 'underscore';
import {React, FeatureUsageStore, Actions, AccountStore,
import {FeatureUsageStore, Actions, AccountStore,
DatabaseStore, Message, CategoryStore} from 'nylas-exports';
import {FeatureUsedUpModal} from 'nylas-component-kit'
import SnoozeUtils from './snooze-utils'
import {PLUGIN_ID, PLUGIN_NAME} from './snooze-constants';
import SnoozeActions from './snooze-actions';
@ -68,44 +67,16 @@ class SnoozeStore {
}
};
_showFeatureLimit() {
const featureData = FeatureUsageStore.featureData("snooze");
let headerText = "";
let rechargeText = ""
if (!featureData.quota) {
headerText = "Snooze not yet enabled";
rechargeText = "Upgrade to Pro to start snoozing"
} else {
headerText = "All snoozes used";
const next = FeatureUsageStore.nextPeriodString(featureData.period)
rechargeText = `Youll have ${featureData.quota} more snoozes ${next}`
}
Actions.openModal({
component: (
<FeatureUsedUpModal
modalClass="snooze"
featureName="Snooze"
headerText={headerText}
iconUrl="nylas://thread-snooze/assets/ic-snooze-modal@2x.png"
rechargeText={rechargeText}
/>
),
height: 575,
width: 412,
})
}
onSnoozeThreads = (threads, snoozeDate, label) => {
if (!FeatureUsageStore.isUsable("snooze")) {
this._showFeatureLimit();
return Promise.resolve()
const lexicon = {
displayName: "Snooze",
usedUpHeader: "All Snoozes used",
iconUrl: "nylas://thread-snooze/assets/ic-snooze-modal@2x.png",
}
this.recordSnoozeEvent(threads, snoozeDate, label)
return FeatureUsageStore.useFeature('snooze')
FeatureUsageStore.asyncUseFeature('snooze', {lexicon})
.then(() => {
this.recordSnoozeEvent(threads, snoozeDate, label)
return SnoozeUtils.moveThreadsToSnooze(threads, this.snoozeCategoriesPromise, snoozeDate)
})
.then((updatedThreads) => {
@ -128,10 +99,14 @@ class SnoozeStore {
});
})
.catch((error) => {
if (error instanceof FeatureUsageStore.NoProAccess) {
return
}
SnoozeUtils.moveThreadsFromSnooze(threads, this.snoozeCategoriesPromise)
Actions.closePopover();
NylasEnv.reportError(error);
NylasEnv.showErrorDialog(`Sorry, we were unable to save your snooze settings. ${error.message}`);
return
});
};

View file

@ -11,13 +11,13 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
IdentityStore._identity.feature_usage = {
"is-usable": {
quota: 10,
peroid: 'monthly',
period: 'monthly',
used_in_period: 8,
feature_limit_name: 'Usable Group A',
},
"not-usable": {
quota: 10,
peroid: 'monthly',
period: 'monthly',
used_in_period: 10,
feature_limit_name: 'Unusable Group A',
},
@ -28,23 +28,23 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
IdentityStore._identity = this.oldIdent
});
describe("isUsable", () => {
describe("_isUsable", () => {
it("returns true if a feature hasn't met it's quota", () => {
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true)
expect(FeatureUsageStore._isUsable("is-usable")).toBe(true)
});
it("returns false if a feature is at its quota", () => {
expect(FeatureUsageStore.isUsable("not-usable")).toBe(false)
expect(FeatureUsageStore._isUsable("not-usable")).toBe(false)
});
it("warns if asking for an unsupported feature", () => {
spyOn(NylasEnv, "reportError")
expect(FeatureUsageStore.isUsable("unsupported")).toBe(false)
expect(FeatureUsageStore._isUsable("unsupported")).toBe(false)
expect(NylasEnv.reportError).toHaveBeenCalled()
});
});
describe("useFeature", () => {
describe("_markFeatureUsed", () => {
beforeEach(() => {
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success));
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => {
@ -61,29 +61,89 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
})
it("returns the num remaining if successful", async () => {
let numLeft = await FeatureUsageStore.useFeature('is-usable');
let numLeft = await FeatureUsageStore._markFeatureUsed('is-usable');
expect(numLeft).toBe(1)
numLeft = await FeatureUsageStore.useFeature('is-usable');
numLeft = await FeatureUsageStore._markFeatureUsed('is-usable');
expect(numLeft).toBe(0)
});
});
it("throws if it was over quota", async () => {
try {
await FeatureUsageStore.useFeature("not-usable");
throw new Error("This should throw")
} catch (err) {
expect(err.message).toMatch(/not usable/)
}
describe("use feature", () => {
beforeEach(() => {
spyOn(FeatureUsageStore, "_markFeatureUsed").andReturn(Promise.resolve());
spyOn(Actions, "openModal")
});
it("throws if using an unsupported feature", async () => {
spyOn(NylasEnv, "reportError")
try {
await FeatureUsageStore.useFeature("unsupported");
throw new Error("This should throw")
} catch (err) {
expect(err.message).toMatch(/supported/)
}
it("marks the feature used if you have pro access", async () => {
spyOn(IdentityStore, "hasProAccess").andReturn(true);
await FeatureUsageStore.asyncUseFeature('not-usable')
expect(FeatureUsageStore._markFeatureUsed).toHaveBeenCalled();
expect(FeatureUsageStore._markFeatureUsed.callCount).toBe(1);
});
it("marks the feature used if it's usable", async () => {
spyOn(IdentityStore, "hasProAccess").andReturn(false);
await FeatureUsageStore.asyncUseFeature('is-usable')
expect(FeatureUsageStore._markFeatureUsed).toHaveBeenCalled();
expect(FeatureUsageStore._markFeatureUsed.callCount).toBe(1);
});
describe("showing modal", () => {
beforeEach(() => {
this.hasProAccess = false;
spyOn(IdentityStore, "hasProAccess").andCallFake(() => {
return this.hasProAccess;
})
this.lexicon = {
displayName: "Test Name",
rechargeCTA: "recharge me",
usedUpHeader: "all test used",
iconUrl: "icon url",
}
});
it("resolves the modal if you upgrade", async () => {
setImmediate(() => {
this.hasProAccess = true;
FeatureUsageStore._onModalClose()
})
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
expect(Actions.openModal).toHaveBeenCalled();
expect(Actions.openModal.calls.length).toBe(1)
});
it("pops open a modal with the correct text", async () => {
setImmediate(() => {
this.hasProAccess = true;
FeatureUsageStore._onModalClose()
})
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
expect(Actions.openModal).toHaveBeenCalled();
expect(Actions.openModal.calls.length).toBe(1)
const component = Actions.openModal.calls[0].args[0].component;
expect(component.props).toEqual({
modalClass: "not-usable",
featureName: "Test Name",
headerText: "all test used",
iconUrl: "icon url",
rechargeText: "Youll have 10 more next month",
})
});
it("rejects if you don't upgrade", async () => {
let caughtError = false;
setImmediate(() => {
this.hasProAccess = false;
FeatureUsageStore._onModalClose()
})
try {
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
} catch (err) {
expect(err instanceof FeatureUsageStore.NoProAccess).toBe(true)
caughtError = true;
}
expect(caughtError).toBe(true)
});
});
});
});

View file

@ -0,0 +1,95 @@
import React from 'react'
import Webview from './webview'
import Actions from '../flux/actions'
import IdentityStore from '../flux/stores/identity-store'
export default class BillingModal extends React.Component {
static propTypes = {
upgradeUrl: React.PropTypes.string,
source: React.PropTypes.string,
}
constructor(props = {}) {
super(props);
this.state = {
src: props.upgradeUrl,
}
}
componentWillMount() {
if (!this.state.src) {
IdentityStore.fetchSingleSignOnURL("/payment?embedded=true").then((url) => {
if (!this._mounted) return;
this.setState({src: url})
})
}
}
componentDidMount() {
this._mounted = true;
}
/**
* The Billing modal can get closed for any number of reasons. The user
* may push escape, click continue below, or click outside of the area.
* Regardless of the method, Actions.closeModal will fire. The
* FeatureUsageStore listens for Actions.closeModal and looks at the
* IdentityStore.hasProAccess to determine if the user succesffully paid
* us or not.
*/
componentWillUnmount() {
this._mounted = false;
}
_onDidFinishLoad = (webview) => {
/**
* Ahh webviews
*
* First we wait for the payment success screen to pop up and do a
* quick assertion on the data that's there.
*
* We then start listening to the continue button, using the console
* as a communication bus.
*/
const receiveUserInfo = `
var a = document.querySelector('#payment-success-data');
result = a ? a.innerText : null;
`;
webview.executeJavaScript(receiveUserInfo, false, async (result) => {
if (!result) return;
if (result !== IdentityStore.identityId()) {
NylasEnv.reportError(new Error("billing.nylas.com/payment_success did not have a valid #payment-success-data field"))
}
const listenForContinue = `
var el = document.querySelector('#continue-btn');
if (el) {el.addEventListener('click', function(event) {console.log("continue clicked")})}
`;
webview.executeJavaScript(listenForContinue);
webview.addEventListener("console-message", (e) => {
if (e.message === "continue clicked") {
// See comment on componentWillUnmount
Actions.closeModal()
}
})
await IdentityStore.asyncRefreshIdentity();
});
/**
* If we see any links on the page, we should open them in new
* windows
*/
const openExternalLink = `
var el = document.querySelector('a');
if (el) {el.addEventListener('click', function(event) {console.log(this.href); event.preventDefault(); return false;})}
`;
webview.executeJavaScript(openExternalLink);
}
render() {
return (
<div className="modal-wrap billing-modal">
<Webview src={this.state.src} onDidFinishLoad={this._onDidFinishLoad} />
</div>
)
}
}

View file

@ -1,50 +1,75 @@
import React from 'react'
import {shell} from 'electron'
import Actions from '../flux/actions'
import RetinaImg from './retina-img'
import OpenIdentityPageButton from './open-identity-page-button'
import BillingModal from './billing-modal'
import IdentityStore from '../flux/stores/identity-store'
export default function FeatureUsedUpModal(props = {}) {
const gotoFeatures = () => shell.openExternal("https://nylas.com/nylas-pro");
return (
<div className={`feature-usage-modal ${props.modalClass}`}>
<div className="feature-header">
<div className="icon">
<RetinaImg
url={props.iconUrl}
style={{position: "relative", top: "-2px"}}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<h2 className="header-text">{props.headerText}</h2>
<p className="recharge-text">{props.rechargeText}</p>
</div>
<div className="feature-cta">
<h2>Want to use <span className="feature-name">{props.featureName} more</span>?</h2>
<div className="pro-description">
<h3>Nylas Pro includes:</h3>
<ul>
<li>Unlimited snoozing</li>
<li>Unlimited reminders</li>
<li>Unlimited delayed sends</li>
</ul>
<p>&hellip; plus <a onClick={gotoFeatures}>dozens of other features</a></p>
</div>
export default class FeatureUsedUpModal extends React.Component {
static propTypes = {
modalClass: React.PropTypes.string.isRequired,
featureName: React.PropTypes.string.isRequired,
headerText: React.PropTypes.string.isRequired,
rechargeText: React.PropTypes.string.isRequired,
iconUrl: React.PropTypes.string.isRequired,
}
<OpenIdentityPageButton
label="Upgrade"
path="/dashboard?upgrade_to_pro=true"
source={`${props.featureName}-Limit-Modal`}
campaign="Limit-Modals"
isCTA
/>
componentDidMount() {
this._mounted = true;
const start = Date.now()
IdentityStore.fetchSingleSignOnURL("/payment?embedded=true").then((url) => {
console.log("Done grabbing url", Date.now() - start)
if (!this._mounted) return
this.setState({upgradeUrl: url})
})
}
componentWillUnmount() {
this._mounted = false;
}
render() {
const gotoFeatures = () => shell.openExternal("https://nylas.com/nylas-pro");
const upgrade = (e) => {
e.stopPropagation();
Actions.openModal({
component: (
<BillingModal source="feature-limit" upgradeUrl={this.state.upgradeUrl} />
),
height: 575,
width: 412,
})
}
return (
<div className={`feature-usage-modal ${this.props.modalClass}`}>
<div className="feature-header">
<div className="icon">
<RetinaImg
url={this.props.iconUrl}
style={{position: "relative", top: "-2px"}}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<h2 className="header-text">{this.props.headerText}</h2>
<p className="recharge-text">{this.props.rechargeText}</p>
</div>
<div className="feature-cta">
<h2>Want to <span className="feature-name">{this.props.featureName} more</span>?</h2>
<div className="pro-description">
<h3>Nylas Pro includes:</h3>
<ul>
<li>Unlimited Snoozing</li>
<li>Unlimited Reminders</li>
<li>Unlimited Mail Merge</li>
</ul>
<p>&hellip; plus <a onClick={gotoFeatures}>dozens of other features</a></p>
</div>
<button className="btn btn-cta btn-emphasis" onClick={upgrade}>Upgrade</button>
</div>
</div>
</div>
)
}
FeatureUsedUpModal.propTypes = {
modalClass: React.PropTypes.string.isRequired,
featureName: React.PropTypes.string.isRequired,
headerText: React.PropTypes.string.isRequired,
rechargeText: React.PropTypes.string.isRequired,
iconUrl: React.PropTypes.string.isRequired,
)
}
}

View file

@ -0,0 +1,190 @@
import url from 'url'
import React from 'react'
import {shell} from 'electron'
import ReactDOM from 'react-dom'
import classnames from 'classnames';
import networkErrors from 'chromium-net-errors';
import RetinaImg from './retina-img'
class InitialLoadingCover extends React.Component {
static propTypes = {
ready: React.PropTypes.bool,
error: React.PropTypes.string,
onTryAgain: React.PropTypes.func,
}
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this._slowTimeout = setTimeout(() => {
this.setState({slow: true});
}, 3500);
}
componentWillUnmount() {
clearTimeout(this._slowTimeout);
this._slowTimeout = null;
}
render() {
const classes = classnames({
'webview-cover': true,
'ready': this.props.ready,
'error': this.props.error,
'slow': this.state.slow,
});
let message = this.props.error;
if (this.props.error) {
message = this.props.error;
} else if (this.state.slow) {
message = "Still trying to reach Nylas…";
} else {
message = '&nbsp;'
}
return (
<div className={classes}>
<div style={{flex: 1}} />
<RetinaImg
className="spinner"
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
<div className="message">{message}</div>
<div className="btn try-again" onClick={this.props.onTryAgain}>Try Again</div>
<div style={{flex: 1}} />
</div>
);
}
}
export default class Webview extends React.Component {
static displayName = "Webview";
static propTypes = {
src: React.PropTypes.string,
onDidFinishLoad: React.PropTypes.func,
}
constructor(props) {
super(props);
this.state = {
ready: false,
error: null,
};
}
componentDidMount() {
this._mounted = true;
this._setupWebview(this.props);
}
componentWillReceiveProps(nextProps = {}) {
if (this.props.src !== nextProps.src) {
this.setState({error: null, webviewLoading: true, ready: false});
this._setupWebview(nextProps)
}
}
componentWillUnmount() {
this._mounted = false;
}
_setupWebview(props) {
if (!props.src) return
const webview = ReactDOM.findDOMNode(this.refs.webview);
const listeners = {
'did-fail-load': this._webviewDidFailLoad,
'did-finish-load': this._webviewDidFinishLoad,
'did-get-response-details': this._webviewDidGetResponseDetails,
'console-message': this._onConsoleMessage,
}
for (const event of Object.keys(listeners)) {
webview.removeEventListener(event, listeners[event]);
}
webview.partition = 'in-memory-only';
webview.src = props.src;
for (const event of Object.keys(listeners)) {
webview.addEventListener(event, listeners[event]);
}
}
_onTryAgain = () => {
const webview = ReactDOM.findDOMNode(this.refs.webview);
webview.reload();
}
_onConsoleMessage = (e) => {
if (/^http.+/i.test(e.message)) { shell.openExternal(e.message) }
console.log('Guest page logged a message:', e.message);
}
_webviewDidGetResponseDetails = ({httpResponseCode, originalURL}) => {
if (!this._mounted) return;
if (!originalURL.includes(url.parse(this.props.src).host)) {
// This means that some other secondarily loaded resource (like
// analytics or Linkedin, etc) got a response. We don't care about
// that.
return
}
if (httpResponseCode >= 400) {
const error = `
Could not reach Nylas. Please try again or contact
support@nylas.com if the issue persists.
(${originalURL}: ${httpResponseCode})
`;
this.setState({ready: false, error: error, webviewLoading: false});
}
this.setState({webviewLoading: false})
};
_webviewDidFailLoad = ({errorCode, validatedURL}) => {
if (!this._mounted) return;
// "Operation was aborted" can be fired when we move between pages quickly.
if (errorCode === -3) {
return;
}
const e = networkErrors.createByCode(errorCode);
const error = `Could not reach ${validatedURL}. ${e ? e.message : errorCode}`;
this.setState({ready: false, error: error, webviewLoading: false});
}
_webviewDidFinishLoad = () => {
if (!this._mounted) return;
// this is sometimes called right after did-fail-load
if (this.state.error) return;
this.setState({ready: true, webviewLoading: false});
if (!this.props.onDidFinishLoad) return;
const webview = ReactDOM.findDOMNode(this.refs.webview);
this.props.onDidFinishLoad(webview)
}
render() {
return (
<div className="webview-wrap">
<webview ref="webview" is partition="in-memory-only" />
<div className={`webview-loading-spinner loading-${this.state.webviewLoading}`}>
<RetinaImg
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<InitialLoadingCover
ready={this.state.ready}
error={this.state.error}
onTryAgain={this._onTryAgain}
/>
</div>
);
}
}

View file

@ -1,10 +1,14 @@
import Rx from 'rx-lite'
import React from 'react'
import NylasStore from 'nylas-store'
import {FeatureUsedUpModal} from 'nylas-component-kit'
import Actions from '../actions'
import IdentityStore from './identity-store'
import TaskQueueStatusStore from './task-queue-status-store'
import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task'
class NoProAccess extends Error { }
/**
* FeatureUsageStore is backed by the IdentityStore
*
@ -52,17 +56,94 @@ import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task'
* 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'unlimited'
*/
class FeatureUsageStore extends NylasStore {
constructor() {
super()
this._waitForModalClose = []
this.NoProAccess = NoProAccess
}
activate() {
/**
* The IdentityStore triggers both after we update it, and when it
* polls for new data every several minutes or so.
*/
this._sub = Rx.Observable.fromStore(IdentityStore).subscribe(() => {
this._disp = Rx.Observable.fromStore(IdentityStore).subscribe(() => {
this.trigger()
})
this._usub = Actions.closeModal.listen(this._onModalClose)
}
deactivate() {
this._disp.dispose();
this._usub()
}
async asyncUseFeature(feature, {lexicon = {}} = {}) {
if (IdentityStore.hasProAccess() || this._isUsable(feature)) {
return this._markFeatureUsed(feature)
}
const {headerText, rechargeText} = this._modalText(feature, lexicon)
Actions.openModal({
component: (
<FeatureUsedUpModal
modalClass={feature}
featureName={lexicon.displayName}
headerText={headerText}
iconUrl={lexicon.iconUrl}
rechargeText={rechargeText}
/>
),
height: 575,
width: 412,
})
return new Promise((resolve, reject) => {
this._waitForModalClose.push({resolve, reject, feature})
})
}
featureData(feature) {
_onModalClose = async () => {
for (const {feature, resolve, reject} of this._waitForModalClose) {
if (IdentityStore.hasProAccess() || this._isUsable(feature)) {
await this._markFeatureUsed(feature)
resolve()
} else {
reject(new NoProAccess(feature))
}
}
this._waitForModalClose = []
}
_modalText(feature, lexicon = {}) {
const featureData = this._featureData(feature);
let headerText = "";
let rechargeText = ""
if (!featureData.quota) {
headerText = `${lexicon.displayName} not yet enabled`;
rechargeText = `Upgrade to Pro to use ${lexicon.displayName}`
} else {
headerText = lexicon.usedUpHeader || "You've reached your quota";
let time = "later";
if (featureData.period === "hourly") {
time = "next hour"
} else if (featureData.period === "daily") {
time = "tomorrow"
} else if (featureData.period === "weekly") {
time = "next week"
} else if (featureData.period === "monthly") {
time = "next month"
} else if (featureData.period === "yearly") {
time = "next year"
} else if (featureData.period === "unlimited") {
time = "if you upgrade to Pro"
}
rechargeText = `Youll have ${featureData.quota} more ${time}`
}
return {headerText, rechargeText}
}
_featureData(feature) {
const usage = this._featureUsage()
if (!usage[feature]) {
NylasEnv.reportError(new Error(`${feature} isn't supported`));
@ -71,25 +152,7 @@ class FeatureUsageStore extends NylasStore {
return usage[feature]
}
nextPeriodString(period) {
let time = "later";
if (period === "hourly") {
time = "next hour"
} else if (period === "daily") {
time = "tomorrow"
} else if (period === "weekly") {
time = "next week"
} else if (period === "monthly") {
time = "next month"
} else if (period === "yearly") {
time = "next year"
} else if (period === "unlimited") {
time = "if you upgrade to Pro"
}
return time
}
isUsable(feature) {
_isUsable(feature) {
const usage = this._featureUsage()
if (!usage[feature]) {
NylasEnv.reportError(new Error(`${feature} isn't supported`));
@ -98,10 +161,7 @@ class FeatureUsageStore extends NylasStore {
return usage[feature].used_in_period < usage[feature].quota
}
async useFeature(featureName) {
if (!this.isUsable(featureName)) {
throw new Error(`${featureName} is not usable! Check "FeatureUsageStore.isUsable" first`);
}
async _markFeatureUsed(featureName) {
const task = new SendFeatureUsageEventTask(featureName)
Actions.queueTask(task);
await TaskQueueStatusStore.waitForPerformLocal(task)

View file

@ -54,6 +54,10 @@ class IdentityStore extends NylasStore {
return Utils.deepClone(this._identity);
}
hasProAccess() {
return this._identity && this._identity.has_pro_access
}
identityId() {
if (!this._identity) {
return null;
@ -148,7 +152,7 @@ class IdentityStore extends NylasStore {
* https://paper.dropbox.com/doc/Analytics-ID-Unification-oVDTkakFsiBBbk9aeuiA3
* for the full list of utm_ labels.
*/
fetchSingleSignOnURL(path, {source, campaign, content}) {
fetchSingleSignOnURL(path, {source, campaign, content} = {}) {
if (!this._identity) {
return Promise.reject(new Error("fetchSingleSignOnURL: no identity set."));
}
@ -192,6 +196,11 @@ class IdentityStore extends NylasStore {
});
}
async asyncRefreshIdentity() {
await this._fetchIdentity();
return true
}
async _fetchIdentity() {
if (!this._identity || !this._identity.token) {
return Promise.resolve();

View file

@ -46,7 +46,9 @@ class NylasComponentKit
@load "FixedPopover", 'fixed-popover'
@require "DatePickerPopover", 'date-picker-popover'
@load "Modal", 'modal'
@load "Webview", 'webview'
@load "FeatureUsedUpModal", 'feature-used-up-modal'
@load "BillingModal", 'billing-modal'
@load "OpenIdentityPageButton", 'open-identity-page-button'
@load "Flexbox", 'flexbox'
@load "RetinaImg", 'retina-img'

View file

@ -0,0 +1,14 @@
@import "ui-variables";
.billing-modal {
width: 100%;
height: 100%;
top: 0;
position: absolute;
display: flex;
cursor: default;
.webview-cover {
background: linear-gradient( -10deg, #fff, #f5f6fd );
}
}

View file

@ -0,0 +1,62 @@
.webview-wrap {
flex: 1;
display: flex;
webview {
display: flex;
flex: 1;
}
.webview-loading-spinner {
position: absolute;
right: 17px;
top: 17px;
opacity: 0;
transition: opacity 200ms ease-in-out;
transition-delay: 200ms;
&.loading-true {
opacity: 1;
}
}
.webview-cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #F3F3F3;
opacity: 1;
transition: opacity 200ms ease-out;
display: flex;
flex-direction: column;
align-items: center;
.message {
color: #444;
opacity: 0;
margin-top: 20px;
transition: opacity 200ms ease-out;
}
.try-again {
opacity: 0;
transition: opacity 200ms ease-out;
}
}
.webview-cover.slow,
.webview-cover.error {
.message {
opacity: 1;
max-width: 400px;
}
}
.webview-cover.error {
.spinner { visibility: hidden;}
.try-again {
opacity: 1;
}
}
.webview-cover.ready {
pointer-events: none;
opacity: 0;
}
}

View file

@ -48,3 +48,5 @@
@import "components/search-bar";
@import "components/code-snippet";
@import "components/feature-used-up-modal";
@import "components/webview";
@import "components/billing-modal";