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:
Evan Morikawa 2017-02-03 15:31:31 -08:00
parent f6e48ea7e4
commit e638e94084
16 changed files with 500 additions and 105 deletions

View file

@ -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) {

View file

@ -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) =>

View 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/)
}
});
});
});

View 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

@ -1 +1 @@
Subproject commit d85ca0a9387a1f2ca059e60fd3bf59b7d381cb85
Subproject commit ae7815e42cda1f9e18e43474c601f01d6614af17

View file

@ -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()
});

View file

@ -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`;

View 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)
}
}

View 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
}
}
}

View file

@ -149,7 +149,6 @@ class Actions {
/*
Public: Manage the Nylas identity
*/
static setNylasIdentity = ActionScopeWindow;
static logoutNylasIdentity = ActionScopeWindow;
/*

View 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()

View file

@ -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()
})
}
}

View 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])
}
}
}

View file

@ -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');

View file

@ -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

View file

@ -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()
}
}
}
}