mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
feat(usage): Add a FeatureUsageStore and move Identity to the DB
Summary: This is a WIP Depends on D3799 on billing.nylas.com This adds a `FeatureUsageStore` which determines whether a feature can be used or not. It also allows us to record "using" a feature. Feature Usage is ultimately backed by the Nylas Identity and cached locally in the Identity object. Since feature usage is attached to the Nylas Identity, we move the whole Identity object (except for the ID) to the database. This includes a migration (with tests!) to move the Nylas Identity from the config into the Database. We still, however, need the Nylas ID to stay in the config so it can be synchronously accessed by the /browser process on bootup when determining what windows to show. It's also convenient to know what the Nylas ID is by looking at the config. There's logic (with tests!) to make sure these stay in sync. If you delete the Nylas ID from the config, it'll be the same as logging you out. The schema for the feature usage can be found in more detail on D3799. By the time it reaches Nylas Mail, the Nylas ID object has a `feature_usage` attribute that has each feature (keyed by the feature name) and information about the plans attached to it. The schema Nylas Mail sees looks like: ``` "feature_usage": { "snooze": { quota: 10, peroid: 'monthly', used_in_period: 8, feature_limit_name: 'Snooze Group A', }, } ``` See D3799 for more info about how these are generated. One final change that's in here is how Stores are loaded. Most of our core stores are loaded at require time, but now things like the IdentityStore need to do asynchronous things on activation. In reality most of our stores do this and it's a miracle it hasn't caused more problems! Now when stores activate we optionally look for an `activate` method and `await` for it. This was necessary so downstream classes (like the Onboarding Store), see a fully initialized IdentityStore by the time it's time to use them Test Plan: New tests! Reviewers: khamidou, juan, halla Reviewed By: juan Differential Revision: https://phab.nylas.com/D3808
This commit is contained in:
parent
f6e48ea7e4
commit
e638e94084
16 changed files with 500 additions and 105 deletions
|
@ -137,10 +137,10 @@ class OnboardingStore extends NylasStore {
|
|||
this.trigger();
|
||||
}
|
||||
|
||||
_onAuthenticationJSONReceived = (json) => {
|
||||
_onAuthenticationJSONReceived = async (json) => {
|
||||
const isFirstAccount = AccountStore.accounts().length === 0;
|
||||
|
||||
Actions.setNylasIdentity(json);
|
||||
await IdentityStore.saveIdentity(json);
|
||||
|
||||
setTimeout(() => {
|
||||
if (isFirstAccount) {
|
||||
|
|
|
@ -11,8 +11,6 @@ describe "AutoUpdateManager", ->
|
|||
get: (key) =>
|
||||
if key is 'nylas.accounts'
|
||||
return @accounts
|
||||
if key is 'nylas.identity.id'
|
||||
return @nylasIdentityId
|
||||
if key is 'env'
|
||||
return 'production'
|
||||
onDidChange: (key, callback) =>
|
||||
|
|
82
spec/stores/feature-usage-store-spec.es6
Normal file
82
spec/stores/feature-usage-store-spec.es6
Normal file
|
@ -0,0 +1,82 @@
|
|||
import {TaskQueueStatusStore} from 'nylas-exports'
|
||||
import FeatureUsageStore from '../../src/flux/stores/feature-usage-store'
|
||||
import Task from '../../src/flux/tasks/task'
|
||||
import SendFeatureUsageEventTask from '../../src/flux/tasks/send-feature-usage-event-task'
|
||||
import IdentityStore from '../../src/flux/stores/identity-store'
|
||||
|
||||
describe("FeatureUsageStore", function featureUsageStoreSpec() {
|
||||
beforeEach(() => {
|
||||
this.oldIdent = IdentityStore._identity;
|
||||
IdentityStore._identity = {id: 'foo'}
|
||||
IdentityStore._identity.feature_usage = {
|
||||
"is-usable": {
|
||||
quota: 10,
|
||||
peroid: 'monthly',
|
||||
used_in_period: 8,
|
||||
feature_limit_name: 'Usable Group A',
|
||||
},
|
||||
"not-usable": {
|
||||
quota: 10,
|
||||
peroid: 'monthly',
|
||||
used_in_period: 10,
|
||||
feature_limit_name: 'Unusable Group A',
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
IdentityStore._identity = this.oldIdent
|
||||
});
|
||||
|
||||
describe("isUsable", () => {
|
||||
it("returns true if a feature hasn't met it's quota", () => {
|
||||
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true)
|
||||
});
|
||||
|
||||
it("returns false if a feature is at its quota", () => {
|
||||
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(NylasEnv.reportError).toHaveBeenCalled()
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFeature", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success));
|
||||
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => {
|
||||
IdentityStore._identity = ident
|
||||
})
|
||||
spyOn(TaskQueueStatusStore, "waitForPerformLocal").andReturn(Promise.resolve())
|
||||
});
|
||||
|
||||
it("returns the num remaining if successful", async () => {
|
||||
let numLeft = await FeatureUsageStore.useFeature('is-usable');
|
||||
expect(numLeft).toBe(1)
|
||||
numLeft = await FeatureUsageStore.useFeature('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/)
|
||||
}
|
||||
});
|
||||
|
||||
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/)
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
75
spec/stores/identity-store-spec.es6
Normal file
75
spec/stores/identity-store-spec.es6
Normal file
|
@ -0,0 +1,75 @@
|
|||
import {ipcRenderer} from 'electron';
|
||||
import {KeyManager, DatabaseTransaction, SendFeatureUsageEventTask} from 'nylas-exports'
|
||||
import IdentityStore from '../../src/flux/stores/identity-store'
|
||||
|
||||
const TEST_NYLAS_ID = "icihsnqh4pwujyqihlrj70vh"
|
||||
const TEST_TOKEN = "test-token"
|
||||
|
||||
describe("IdentityStore", function identityStoreSpec() {
|
||||
beforeEach(() => {
|
||||
this.identityJSON = {
|
||||
valid_until: 1500093224,
|
||||
firstname: "Nylas 050",
|
||||
lastname: "Test",
|
||||
free_until: 1500006814,
|
||||
email: "nylas050test@evanmorikawa.com",
|
||||
id: TEST_NYLAS_ID,
|
||||
seen_welcome_page: true,
|
||||
}
|
||||
});
|
||||
|
||||
it("logs out of nylas identity properly", async () => {
|
||||
IdentityStore._identity = this.identityJSON;
|
||||
spyOn(NylasEnv.config, 'unset')
|
||||
spyOn(KeyManager, "deletePassword")
|
||||
spyOn(ipcRenderer, "send")
|
||||
spyOn(DatabaseTransaction.prototype, "persistJSONBlob").andReturn(Promise.resolve())
|
||||
|
||||
const promise = IdentityStore._onLogoutNylasIdentity()
|
||||
IdentityStore._onIdentityChanged(null)
|
||||
return promise.then(() => {
|
||||
expect(KeyManager.deletePassword).toHaveBeenCalled()
|
||||
expect(ipcRenderer.send).toHaveBeenCalled()
|
||||
expect(ipcRenderer.send.calls[0].args[1]).toBe("application:relaunch-to-initial-windows")
|
||||
expect(DatabaseTransaction.prototype.persistJSONBlob).toHaveBeenCalled()
|
||||
const ident = DatabaseTransaction.prototype.persistJSONBlob.calls[0].args[1]
|
||||
expect(ident).toBe(null)
|
||||
})
|
||||
});
|
||||
|
||||
it("can log a feature usage event", () => {
|
||||
spyOn(IdentityStore, "nylasIDRequest");
|
||||
spyOn(IdentityStore, "saveIdentity");
|
||||
IdentityStore._identity = this.identityJSON
|
||||
IdentityStore._identity.token = TEST_TOKEN;
|
||||
IdentityStore._onEnvChanged()
|
||||
const t = new SendFeatureUsageEventTask("snooze");
|
||||
t.performRemote()
|
||||
const opts = IdentityStore.nylasIDRequest.calls[0].args[0]
|
||||
expect(opts).toEqual({
|
||||
method: "POST",
|
||||
url: "https://billing.nylas.com/n1/user/feature_usage_event",
|
||||
body: {
|
||||
feature_name: 'snooze',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
describe("returning the identity object", () => {
|
||||
it("returns the identity as null if it looks blank", () => {
|
||||
IdentityStore._identity = null;
|
||||
expect(IdentityStore.identity()).toBe(null);
|
||||
IdentityStore._identity = {};
|
||||
expect(IdentityStore.identity()).toBe(null);
|
||||
IdentityStore._identity = {token: 'bad'};
|
||||
expect(IdentityStore.identity()).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a proper clone of the identity", () => {
|
||||
IdentityStore._identity = {id: 'bar', deep: {obj: 'baz'}};
|
||||
const ident = IdentityStore.identity();
|
||||
IdentityStore._identity.deep.obj = 'changed';
|
||||
expect(ident.deep.obj).toBe('baz');
|
||||
});
|
||||
});
|
||||
});
|
2
src/K2
2
src/K2
|
@ -1 +1 @@
|
|||
Subproject commit d85ca0a9387a1f2ca059e60fd3bf59b7d381cb85
|
||||
Subproject commit ae7815e42cda1f9e18e43474c601f01d6614af17
|
|
@ -10,6 +10,8 @@ import {EventEmitter} from 'events';
|
|||
|
||||
import WindowManager from './window-manager';
|
||||
import FileListCache from './file-list-cache';
|
||||
import DatabaseReader from './database-reader';
|
||||
import ConfigMigrator from './config-migrator';
|
||||
import ApplicationMenu from './application-menu';
|
||||
import AutoUpdateManager from './auto-update-manager';
|
||||
import SystemTrayManager from './system-tray-manager';
|
||||
|
@ -24,7 +26,7 @@ let clipboard = null;
|
|||
// The application's singleton class.
|
||||
//
|
||||
export default class Application extends EventEmitter {
|
||||
start(options) {
|
||||
async start(options) {
|
||||
const {resourcePath, configDirPath, version, devMode, specMode, safeMode} = options;
|
||||
|
||||
// Normalize to make sure drive letter case is consistent on Windows
|
||||
|
@ -38,12 +40,18 @@ export default class Application extends EventEmitter {
|
|||
this.fileListCache = new FileListCache();
|
||||
this.nylasProtocolHandler = new NylasProtocolHandler(this.resourcePath, this.safeMode);
|
||||
|
||||
this.databaseReader = new DatabaseReader({configDirPath, specMode});
|
||||
await this.databaseReader.open();
|
||||
|
||||
const Config = require('../config');
|
||||
const config = new Config();
|
||||
this.config = config;
|
||||
this.configPersistenceManager = new ConfigPersistenceManager({configDirPath, resourcePath});
|
||||
config.load();
|
||||
|
||||
this.configMigrator = new ConfigMigrator(this.config, this.databaseReader);
|
||||
this.configMigrator.migrate()
|
||||
|
||||
this.packageMigrationManager = new PackageMigrationManager({config, configDirPath, version})
|
||||
this.packageMigrationManager.migrate()
|
||||
|
||||
|
@ -52,7 +60,7 @@ export default class Application extends EventEmitter {
|
|||
initializeInBackground = false;
|
||||
}
|
||||
|
||||
this.autoUpdateManager = new AutoUpdateManager(version, config, specMode);
|
||||
this.autoUpdateManager = new AutoUpdateManager(version, config, specMode, this.databaseReader);
|
||||
this.applicationMenu = new ApplicationMenu(version);
|
||||
this.windowManager = new WindowManager({
|
||||
resourcePath: this.resourcePath,
|
||||
|
@ -123,7 +131,6 @@ export default class Application extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// On Windows, removing a file can fail if a process still has it open. When
|
||||
// we close windows and log out, we need to wait for these processes to completely
|
||||
// exit and then delete the file. It's hard to tell when this happens, so we just
|
||||
|
@ -160,7 +167,7 @@ export default class Application extends EventEmitter {
|
|||
openWindowsForTokenState() {
|
||||
const accounts = this.config.get('nylas.accounts');
|
||||
const hasAccount = accounts && accounts.length > 0;
|
||||
const hasN1ID = this.config.get('nylas.identity.id');
|
||||
const hasN1ID = this._getNylasId();
|
||||
|
||||
if (hasAccount && hasN1ID) {
|
||||
this.windowManager.ensureWindow(WindowManager.MAIN_WINDOW);
|
||||
|
@ -173,7 +180,14 @@ export default class Application extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
_getNylasId() {
|
||||
const identity = this.databaseReader.getJSONBlob("NylasID") || {}
|
||||
return identity.id
|
||||
}
|
||||
|
||||
_relaunchToInitialWindows = ({resetConfig, resetDatabase} = {}) => {
|
||||
// This will re-fetch the NylasID to update the feed url
|
||||
this.autoUpdateManager.updateFeedURL()
|
||||
this.setDatabasePhase('close');
|
||||
this.windowManager.destroyAllWindows();
|
||||
|
||||
|
@ -270,6 +284,10 @@ export default class Application extends EventEmitter {
|
|||
|
||||
this.on('application:relaunch-to-initial-windows', this._relaunchToInitialWindows);
|
||||
|
||||
this.on('application:onIdentityChanged', () => {
|
||||
this.autoUpdateManager.updateFeedURL()
|
||||
});
|
||||
|
||||
this.on('application:quit', () => {
|
||||
app.quit()
|
||||
});
|
||||
|
|
|
@ -18,31 +18,28 @@ const preferredChannel = 'nylas-mail'
|
|||
|
||||
export default class AutoUpdateManager extends EventEmitter {
|
||||
|
||||
constructor(version, config, specMode) {
|
||||
constructor(version, config, specMode, databaseReader) {
|
||||
super();
|
||||
|
||||
this.state = IdleState;
|
||||
this.version = version;
|
||||
this.config = config;
|
||||
this.databaseReader = databaseReader
|
||||
this.specMode = specMode;
|
||||
this.preferredChannel = preferredChannel;
|
||||
|
||||
this._updateFeedURL();
|
||||
this.updateFeedURL();
|
||||
|
||||
this.config.onDidChange(
|
||||
'nylas.identity.id',
|
||||
this._updateFeedURL
|
||||
);
|
||||
this.config.onDidChange(
|
||||
'nylas.accounts',
|
||||
this._updateFeedURL
|
||||
this.updateFeedURL
|
||||
);
|
||||
|
||||
setTimeout(() => this.setupAutoUpdater(), 0);
|
||||
}
|
||||
|
||||
parameters = () => {
|
||||
let updaterId = this.config.get("nylas.identity.id");
|
||||
let updaterId = (this.databaseReader.getJSONBlob("NylasID") || {}).id
|
||||
if (!updaterId) {
|
||||
updaterId = "anonymous";
|
||||
}
|
||||
|
@ -66,7 +63,7 @@ export default class AutoUpdateManager extends EventEmitter {
|
|||
};
|
||||
}
|
||||
|
||||
_updateFeedURL = () => {
|
||||
updateFeedURL = () => {
|
||||
const params = this.parameters();
|
||||
|
||||
let host = `edgehill.nylas.com`;
|
||||
|
|
25
src/browser/config-migrator.es6
Normal file
25
src/browser/config-migrator.es6
Normal file
|
@ -0,0 +1,25 @@
|
|||
export default class ConfigMigrator {
|
||||
constructor(config, database) {
|
||||
this.config = config;
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
migrate() {
|
||||
/**
|
||||
* In version before 1.0.21 we stored the Nylas ID Identity in the Config.
|
||||
* After 1.0.21 we moved it into the JSONBlob Database Store.
|
||||
*/
|
||||
const oldIdentity = this.config.get("nylas.identity") || {};
|
||||
if (!oldIdentity.id) return;
|
||||
const key = "NylasID"
|
||||
const q = `REPLACE INTO JSONBlob (id, data, client_id) VALUES (?,?,?)`;
|
||||
const jsonBlobData = {
|
||||
id: key,
|
||||
clientId: key,
|
||||
serverId: key,
|
||||
json: oldIdentity,
|
||||
}
|
||||
this.database.database.prepare(q).run([key, JSON.stringify(jsonBlobData), key])
|
||||
this.config.set("nylas.identity", null)
|
||||
}
|
||||
}
|
22
src/browser/database-reader.es6
Normal file
22
src/browser/database-reader.es6
Normal file
|
@ -0,0 +1,22 @@
|
|||
import {setupDatabase, databasePath} from '../database-helpers'
|
||||
|
||||
export default class DatabaseReader {
|
||||
constructor({configDirPath, specMode}) {
|
||||
this.databasePath = databasePath(configDirPath, specMode)
|
||||
}
|
||||
|
||||
async open() {
|
||||
this.database = await setupDatabase(this.databasePath)
|
||||
}
|
||||
|
||||
getJSONBlob(key) {
|
||||
const q = `SELECT * FROM JSONBlob WHERE id = '${key}'`;
|
||||
try {
|
||||
const row = this.database.prepare(q).get();
|
||||
if (!row || !row.data) return null
|
||||
return (JSON.parse(row.data) || {}).json
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -149,7 +149,6 @@ class Actions {
|
|||
/*
|
||||
Public: Manage the Nylas identity
|
||||
*/
|
||||
static setNylasIdentity = ActionScopeWindow;
|
||||
static logoutNylasIdentity = ActionScopeWindow;
|
||||
|
||||
/*
|
||||
|
|
51
src/flux/stores/feature-usage-store.es6
Normal file
51
src/flux/stores/feature-usage-store.es6
Normal file
|
@ -0,0 +1,51 @@
|
|||
import Rx from 'rx-lite'
|
||||
import NylasStore from 'nylas-store'
|
||||
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'
|
||||
|
||||
/**
|
||||
* FeatureUsageStore is backed by the IdentityStore
|
||||
*
|
||||
* The billing site is responsible for returning with the Identity object
|
||||
* a usage hash that includes all supported features, their quotas for the
|
||||
* user, and the current usage of that user. We keep a cache locally
|
||||
*/
|
||||
class FeatureUsageStore extends NylasStore {
|
||||
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.trigger()
|
||||
})
|
||||
}
|
||||
|
||||
isUsable(feature) {
|
||||
const usage = this._featureUsage()
|
||||
if (!usage[feature]) {
|
||||
NylasEnv.reportError(`${feature} isn't supported`);
|
||||
return false
|
||||
}
|
||||
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`);
|
||||
}
|
||||
const task = new SendFeatureUsageEventTask(featureName)
|
||||
Actions.queueTask(task);
|
||||
await TaskQueueStatusStore.waitForPerformLocal(task)
|
||||
const feat = IdentityStore.identity().feature_usage[featureName]
|
||||
return feat.quota - feat.used_in_period
|
||||
}
|
||||
|
||||
_featureUsage() {
|
||||
return Object.assign({}, IdentityStore.identity().feature_usage) || {}
|
||||
}
|
||||
}
|
||||
|
||||
export default new FeatureUsageStore()
|
|
@ -1,40 +1,132 @@
|
|||
import Rx from 'rx-lite'
|
||||
import NylasStore from 'nylas-store';
|
||||
import {ipcRenderer} from 'electron';
|
||||
import request from 'request';
|
||||
import url from 'url'
|
||||
|
||||
import KeyManager from '../../key-manager'
|
||||
import Actions from '../actions';
|
||||
import Utils from '../models/utils';
|
||||
|
||||
const configIdentityKey = "nylas.identity";
|
||||
import Actions from '../actions';
|
||||
import KeyManager from '../../key-manager'
|
||||
import DatabaseStore from './database-store'
|
||||
|
||||
// Note this key name is used when migrating to Nylas Pro accounts from
|
||||
// old N1.
|
||||
const KEY_NAME = 'Nylas Account';
|
||||
const KEYCHAIN_NAME = 'Nylas Account';
|
||||
|
||||
class IdentityStore extends NylasStore {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._savePromises = []
|
||||
this._identity = null
|
||||
}
|
||||
|
||||
async activate() {
|
||||
NylasEnv.config.onDidChange('env', this._onEnvChanged);
|
||||
this._onEnvChanged();
|
||||
|
||||
this.listenTo(Actions.setNylasIdentity, this._onSetNylasIdentity);
|
||||
this.listenTo(Actions.logoutNylasIdentity, this._onLogoutNylasIdentity);
|
||||
|
||||
NylasEnv.config.onDidChange(configIdentityKey, () => {
|
||||
this._loadIdentity();
|
||||
this.trigger();
|
||||
});
|
||||
const q = DatabaseStore.findJSONBlob("NylasID");
|
||||
this._disp = Rx.Observable.fromQuery(q).subscribe(this._onIdentityChanged)
|
||||
|
||||
this._loadIdentity();
|
||||
const identity = await DatabaseStore.run(q)
|
||||
this._onIdentityChanged(identity)
|
||||
|
||||
if (NylasEnv.isMainWindow() && ['staging', 'production'].includes(NylasEnv.config.get('env'))) {
|
||||
setInterval(this.fetchIdentity, 1000 * 60 * 60); // 1 hour
|
||||
this.fetchIdentity();
|
||||
this._fetchAndPollRemoteIdentity()
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._disp.dispose();
|
||||
this.stopListeningToAll()
|
||||
}
|
||||
|
||||
identity() {
|
||||
if (!this._identity || !this._identity.id) return null
|
||||
return Utils.deepClone(this._identity);
|
||||
}
|
||||
|
||||
identityId() {
|
||||
if (!this._identity) {
|
||||
return null;
|
||||
}
|
||||
return this._identity.id;
|
||||
}
|
||||
|
||||
_fetchAndPollRemoteIdentity() {
|
||||
if (!NylasEnv.isMainWindow()) return;
|
||||
if (!['staging', 'production'].includes(NylasEnv.config.get('env'))) return;
|
||||
/**
|
||||
* We only need to re-fetch the identity to synchronize ourselves
|
||||
* with any changes a user did on a separate computer. Any updates
|
||||
* they do on their primary computer will be optimistically updated.
|
||||
* We also update from the server's version every
|
||||
* `SendFeatureUsageEventTask`
|
||||
*/
|
||||
setInterval(this._fetchIdentity, 1000 * 60 * 10); // 10 minutes
|
||||
// Don't await for this!
|
||||
this._fetchIdentity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the identity to the database. The local cache will be updated
|
||||
* once the database change comes back through
|
||||
*/
|
||||
async saveIdentity(identity) {
|
||||
if (identity && identity.token) {
|
||||
KeyManager.replacePassword(KEYCHAIN_NAME, identity.token)
|
||||
delete identity.token;
|
||||
}
|
||||
if (!identity) {
|
||||
KeyManager.deletePassword(KEYCHAIN_NAME)
|
||||
}
|
||||
const savePromise = new Promise((resolve, reject) => {
|
||||
this._savePromises.push({
|
||||
resolve: resolve,
|
||||
rejectTimeout: setTimeout(() => {
|
||||
reject(new Error("Identity never persisted to database"))
|
||||
}, 10000),
|
||||
});
|
||||
})
|
||||
await DatabaseStore.inTransaction((t) => {
|
||||
return t.persistJSONBlob("NylasID", identity)
|
||||
});
|
||||
return savePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* When the identity changes in the database, update our local store
|
||||
* cache and set the token from the keychain.
|
||||
*/
|
||||
_onIdentityChanged = (newIdentity) => {
|
||||
const oldId = ((this._identity || {}).id)
|
||||
this._identity = newIdentity
|
||||
if (this._identity && this._identity.id) {
|
||||
if (!this._identity.token) {
|
||||
this._identity.token = KeyManager.getPassword(KEYCHAIN_NAME);
|
||||
}
|
||||
} else {
|
||||
// It's possible the identity exists as an empty object. If the
|
||||
// object looks blank, set the identity to null.
|
||||
this._identity = null
|
||||
}
|
||||
const newId = ((this._identity || {}).id);
|
||||
if (oldId !== newId) {
|
||||
ipcRenderer.send('command', 'onIdentityChanged');
|
||||
}
|
||||
if (this._savePromises.length > 0) {
|
||||
for (const {resolve, rejectTimeout} of this._savePromises) {
|
||||
resolve();
|
||||
clearTimeout(rejectTimeout)
|
||||
}
|
||||
this._savePromises = []
|
||||
}
|
||||
this.trigger();
|
||||
}
|
||||
|
||||
_onLogoutNylasIdentity = async () => {
|
||||
await this.saveIdentity(null)
|
||||
ipcRenderer.send('command', 'application:relaunch-to-initial-windows');
|
||||
}
|
||||
|
||||
_onEnvChanged = () => {
|
||||
|
@ -50,24 +142,6 @@ class IdentityStore extends NylasStore {
|
|||
}
|
||||
}
|
||||
|
||||
_loadIdentity() {
|
||||
this._identity = NylasEnv.config.get(configIdentityKey);
|
||||
if (this._identity) {
|
||||
this._identity.token = KeyManager.getPassword(KEY_NAME, {migrateFromService: "Nylas"});
|
||||
}
|
||||
}
|
||||
|
||||
identity() {
|
||||
return this._identity;
|
||||
}
|
||||
|
||||
identityId() {
|
||||
if (!this._identity) {
|
||||
return null;
|
||||
}
|
||||
return this._identity.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* This passes utm_source, utm_campaign, and utm_content params to the
|
||||
* N1 billing site. Please reference:
|
||||
|
@ -118,30 +192,37 @@ class IdentityStore extends NylasStore {
|
|||
});
|
||||
}
|
||||
|
||||
fetchIdentity = () => {
|
||||
_fetchIdentity = async () => {
|
||||
if (!this._identity || !this._identity.token) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.fetchPath('/n1/user').then((json) => {
|
||||
const nextIdentity = Object.assign({}, this._identity, json);
|
||||
this._onSetNylasIdentity(nextIdentity);
|
||||
});
|
||||
const json = await this.fetchPath('/n1/user')
|
||||
const nextIdentity = Object.assign({}, this._identity, json);
|
||||
return this.saveIdentity(nextIdentity);
|
||||
}
|
||||
|
||||
fetchPath = (path) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = Utils.generateTempId();
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${this.URLRoot}${path}`,
|
||||
startTime: Date.now(),
|
||||
auth: {
|
||||
username: this._identity.token,
|
||||
password: '',
|
||||
sendImmediately: true,
|
||||
},
|
||||
};
|
||||
fetchPath = async (path) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${this.URLRoot}${path}`,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
try {
|
||||
await this.nylasIDRequest(options)
|
||||
} catch (err) {
|
||||
const error = err || new Error(`IdentityStore.fetchPath: ${path} ${err.message}.`)
|
||||
NylasEnv.reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
nylasIDRequest(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.auth = {
|
||||
username: this._identity.token,
|
||||
password: '',
|
||||
sendImmediately: true,
|
||||
}
|
||||
const requestId = Utils.generateTempId();
|
||||
Actions.willMakeAPIRequest({
|
||||
request: options,
|
||||
requestId: requestId,
|
||||
|
@ -157,27 +238,12 @@ class IdentityStore extends NylasStore {
|
|||
try {
|
||||
return resolve(JSON.parse(body));
|
||||
} catch (err) {
|
||||
NylasEnv.reportError(new Error(`IdentityStore.fetchPath: ${path} ${err.message}.`))
|
||||
return reject(err)
|
||||
}
|
||||
}
|
||||
return reject(error || new Error(`IdentityStore.fetchPath: ${path} ${response.statusCode}.`));
|
||||
return reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_onLogoutNylasIdentity = () => {
|
||||
KeyManager.deletePassword(KEY_NAME);
|
||||
NylasEnv.config.unset(configIdentityKey);
|
||||
ipcRenderer.send('command', 'application:relaunch-to-initial-windows');
|
||||
}
|
||||
|
||||
_onSetNylasIdentity = (identity) => {
|
||||
if (identity.token) {
|
||||
KeyManager.replacePassword(KEY_NAME, identity.token)
|
||||
delete identity.token;
|
||||
}
|
||||
NylasEnv.config.set(configIdentityKey, identity);
|
||||
this.trigger()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
49
src/flux/tasks/send-feature-usage-event-task.es6
Normal file
49
src/flux/tasks/send-feature-usage-event-task.es6
Normal file
|
@ -0,0 +1,49 @@
|
|||
import Task from './task';
|
||||
import NylasAPI from '../nylas-api'
|
||||
import {APIError} from '../errors'
|
||||
import IdentityStore from '../stores/identity-store'
|
||||
|
||||
export default class SendFeatureUsageEventTask extends Task {
|
||||
constructor(featureName) {
|
||||
super();
|
||||
this.featureName = featureName
|
||||
}
|
||||
|
||||
async performLocal(increment = 1) {
|
||||
const newIdent = IdentityStore.identity();
|
||||
if (!newIdent.feature_usage[this.featureName]) {
|
||||
throw new Error(`Can't use ${this.featureName}. Does not exist on identity`)
|
||||
}
|
||||
newIdent.feature_usage[this.featureName].used_in_period += increment
|
||||
await IdentityStore.saveIdentity(newIdent)
|
||||
}
|
||||
|
||||
revert() {
|
||||
this.performLocal(-1)
|
||||
}
|
||||
|
||||
async performRemote() {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: `${IdentityStore.URLRoot}/n1/user/feature_usage_event`,
|
||||
body: {feature_name: this.featureName},
|
||||
};
|
||||
try {
|
||||
const updatedIdentity = await IdentityStore.nylasIDRequest(options);
|
||||
await IdentityStore.saveIdentity(updatedIdentity);
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
} catch (err) {
|
||||
if (err instanceof APIError) {
|
||||
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
|
||||
this.revert()
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
}
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
}
|
||||
|
||||
this.revert()
|
||||
NylasEnv.reportError(err);
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -129,6 +129,7 @@ lazyLoadAndRegisterTask(`SyncbackCategoryTask`, 'syncback-category-task');
|
|||
lazyLoadAndRegisterTask(`SyncbackMetadataTask`, 'syncback-metadata-task');
|
||||
lazyLoadAndRegisterTask(`PerformSendActionTask`, 'perform-send-action-task');
|
||||
lazyLoadAndRegisterTask(`ReprocessMailRulesTask`, 'reprocess-mail-rules-task');
|
||||
lazyLoadAndRegisterTask(`SendFeatureUsageEventTask`, 'send-feature-usage-event-task');
|
||||
lazyLoadAndRegisterTask(`EnsureMessageInSentFolderTask`, 'ensure-message-in-sent-folder-task');
|
||||
|
||||
// Stores
|
||||
|
@ -153,6 +154,7 @@ lazyLoadAndRegisterStore(`WorkspaceStore`, 'workspace-store');
|
|||
lazyLoadAndRegisterStore(`MailRulesStore`, 'mail-rules-store');
|
||||
lazyLoadAndRegisterStore(`FileUploadStore`, 'file-upload-store');
|
||||
lazyLoadAndRegisterStore(`SendActionsStore`, 'send-actions-store');
|
||||
lazyLoadAndRegisterStore(`FeatureUsageStore`, 'feature-usage-store');
|
||||
lazyLoadAndRegisterStore(`ThreadCountsStore`, 'thread-counts-store');
|
||||
lazyLoadAndRegisterStore(`FileDownloadStore`, 'file-download-store');
|
||||
lazyLoadAndRegisterStore(`UpdateChannelStore`, 'update-channel-store');
|
||||
|
|
|
@ -193,6 +193,8 @@ class NylasEnvConstructor
|
|||
unless @inSpecMode()
|
||||
@actionBridge = new ActionBridge(ipcRenderer)
|
||||
|
||||
@extendRxObservables()
|
||||
|
||||
# Nylas exports is designed to provide a lazy-loaded set of globally
|
||||
# accessible objects to all packages. Upon require, nylas-exports will
|
||||
# fill the TaskRegistry, StoreRegistry, and DatabaseObjectRegistries
|
||||
|
@ -657,18 +659,17 @@ class NylasEnvConstructor
|
|||
@savedState.columnWidths ?= {}
|
||||
@savedState.columnWidths[id]
|
||||
|
||||
startWindow: ->
|
||||
startWindow: =>
|
||||
@loadConfig()
|
||||
{packageLoadingDeferred, windowType} = @getLoadSettings()
|
||||
@extendRxObservables()
|
||||
StoreRegistry.activateAllStores()
|
||||
@keymaps.loadKeymaps()
|
||||
@themes.loadBaseStylesheets()
|
||||
@packages.loadPackages(windowType) unless packageLoadingDeferred
|
||||
@deserializePackageStates() unless packageLoadingDeferred
|
||||
@initializeReactRoot()
|
||||
@packages.activate() unless packageLoadingDeferred
|
||||
@menu.update()
|
||||
{packageLoadingDeferred, windowType, title} = @getLoadSettings()
|
||||
return StoreRegistry.activateAllStores().then =>
|
||||
@keymaps.loadKeymaps()
|
||||
@themes.loadBaseStylesheets()
|
||||
@packages.loadPackages(windowType) unless packageLoadingDeferred
|
||||
@deserializePackageStates() unless packageLoadingDeferred
|
||||
@initializeReactRoot()
|
||||
@packages.activate() unless packageLoadingDeferred
|
||||
@menu.update()
|
||||
|
||||
# Call this method when establishing a real application window.
|
||||
startRootWindow: ->
|
||||
|
@ -683,6 +684,8 @@ class NylasEnvConstructor
|
|||
window.requestAnimationFrame =>
|
||||
@displayWindow() unless initializeInBackground
|
||||
@startWindow()
|
||||
# These don't need to wait for the window's stores and such
|
||||
# to fully activate:
|
||||
@requireUserInitScript() unless safeMode
|
||||
@showMainWindow()
|
||||
ipcRenderer.send('window-command', 'window:loaded')
|
||||
|
@ -692,12 +695,11 @@ class NylasEnvConstructor
|
|||
# hot windows), the packages won't be loaded until `populateHotWindow`
|
||||
# gets fired.
|
||||
startSecondaryWindow: ->
|
||||
@extendRxObservables()
|
||||
document.getElementById("application-loading-cover")?.remove()
|
||||
@startWindow()
|
||||
@initializeBasicSheet()
|
||||
ipcRenderer.on("load-settings-changed", @populateHotWindow)
|
||||
ipcRenderer.send('window-command', 'window:loaded')
|
||||
@startWindow().then =>
|
||||
@initializeBasicSheet()
|
||||
ipcRenderer.on("load-settings-changed", @populateHotWindow)
|
||||
ipcRenderer.send('window-command', 'window:loaded')
|
||||
|
||||
# We setup the initial Sheet for hot windows. This is the default title
|
||||
# bar, stoplights, etc. This saves ~100ms when populating the hot
|
||||
|
|
|
@ -9,13 +9,22 @@ class StoreRegistry extends SerializableRegistry {
|
|||
* It also kicks off a fairly large tree of require statements that
|
||||
* takes considerable time to process.
|
||||
*/
|
||||
activateAllStores() {
|
||||
async activateAllStores() {
|
||||
for (const name of Object.keys(this._constructorFactories)) {
|
||||
// All we need to do is hit `require` on the store. This will
|
||||
// construct the object an initialize the require cache. The
|
||||
// stores are now available in nylas-exports or from the node
|
||||
// require cache.
|
||||
this.get(name)
|
||||
const store = this.get(name);
|
||||
|
||||
/**
|
||||
* Some stores may have extra activation work to do. This work may
|
||||
* be asynchronous. We detect that here and call the store's
|
||||
* activate methods.
|
||||
*/
|
||||
if (store.activate) {
|
||||
await store.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue