aboutsummaryrefslogtreecommitdiffstats
path: root/src/stores
diff options
context:
space:
mode:
authorLibravatar Stefan Malzner <stefan@adlk.io>2017-10-13 12:29:40 +0200
committerLibravatar Stefan Malzner <stefan@adlk.io>2017-10-13 12:29:40 +0200
commit58cda9cc7fb79ca9df6746de7f9662bc08dc156a (patch)
tree1211600c2a5d3b5f81c435c6896618111a611720 /src/stores
downloadferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.tar.gz
ferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.tar.zst
ferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.zip
initial commit
Diffstat (limited to 'src/stores')
-rw-r--r--src/stores/AppStore.js309
-rw-r--r--src/stores/GlobalErrorStore.js28
-rw-r--r--src/stores/NewsStore.js42
-rw-r--r--src/stores/PaymentStore.js47
-rw-r--r--src/stores/RecipePreviewsStore.js50
-rw-r--r--src/stores/RecipesStore.js96
-rw-r--r--src/stores/RequestStore.js59
-rw-r--r--src/stores/ServicesStore.js503
-rw-r--r--src/stores/SettingsStore.js55
-rw-r--r--src/stores/UIStore.js34
-rw-r--r--src/stores/UserStore.js272
-rw-r--r--src/stores/index.js34
-rw-r--r--src/stores/lib/CachedRequest.js106
-rw-r--r--src/stores/lib/Reaction.js22
-rw-r--r--src/stores/lib/Request.js112
-rw-r--r--src/stores/lib/Store.js44
16 files changed, 1813 insertions, 0 deletions
diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js
new file mode 100644
index 000000000..a5e0839f2
--- /dev/null
+++ b/src/stores/AppStore.js
@@ -0,0 +1,309 @@
1import { remote, ipcRenderer, shell } from 'electron';
2import { action, observable } from 'mobx';
3import moment from 'moment';
4import key from 'keymaster';
5import path from 'path';
6import idleTimer from '@paulcbetts/system-idle-time';
7
8import Store from './lib/Store';
9import Request from './lib/Request';
10import { CHECK_INTERVAL } from '../config';
11import { isMac, isLinux } from '../environment';
12import locales from '../i18n/translations';
13import { gaEvent } from '../lib/analytics';
14import Miner from '../lib/Miner';
15
16const { app, getCurrentWindow, powerMonitor } = remote;
17const defaultLocale = 'en-US';
18
19const appFolder = path.dirname(process.execPath);
20const updateExe = path.resolve(appFolder, '..', 'Update.exe');
21const exeName = path.basename(process.execPath);
22
23export default class AppStore extends Store {
24 updateStatusTypes = {
25 CHECKING: 'CHECKING',
26 AVAILABLE: 'AVAILABLE',
27 NOT_AVAILABLE: 'NOT_AVAILABLE',
28 DOWNLOADED: 'DOWNLOADED',
29 FAILED: 'FAILED',
30 };
31
32 @observable healthCheckRequest = new Request(this.api.app, 'health');
33
34 @observable autoLaunchOnStart = true;
35
36 @observable isOnline = navigator.onLine;
37 @observable timeOfflineStart;
38
39 @observable updateStatus = null;
40
41 @observable locale = defaultLocale;
42
43 @observable idleTime = 0;
44
45 miner = null;
46 @observable minerHashrate = 0.0;
47
48 constructor(...args: any) {
49 super(...args);
50
51 // Register action handlers
52 this.actions.app.notify.listen(this._notify.bind(this));
53 this.actions.app.setBadge.listen(this._setBadge.bind(this));
54 this.actions.app.launchOnStartup.listen(this._launchOnStartup.bind(this));
55 this.actions.app.openExternalUrl.listen(this._openExternalUrl.bind(this));
56 this.actions.app.checkForUpdates.listen(this._checkForUpdates.bind(this));
57 this.actions.app.installUpdate.listen(this._installUpdate.bind(this));
58 this.actions.app.resetUpdateStatus.listen(this._resetUpdateStatus.bind(this));
59 this.actions.app.healthCheck.listen(this._healthCheck.bind(this));
60
61 this.registerReactions([
62 this._offlineCheck.bind(this),
63 this._setLocale.bind(this),
64 this._handleMiner.bind(this),
65 this._handleMinerThrottle.bind(this),
66 ]);
67 }
68
69 setup() {
70 this._appStartsCounter();
71 // Focus the active service
72 window.addEventListener('focus', this.actions.service.focusActiveService);
73
74 // Online/Offline handling
75 window.addEventListener('online', () => { this.isOnline = true; });
76 window.addEventListener('offline', () => { this.isOnline = false; });
77
78 this.isOnline = navigator.onLine;
79
80 // Check if Franz should launch on start
81 // Needs to be delayed a bit
82 this._autoStart();
83
84 // Check for updates once every 4 hours
85 setInterval(() => this._checkForUpdates(), CHECK_INTERVAL);
86 // Check for an update in 30s (need a delay to prevent Squirrel Installer lock file issues)
87 setTimeout(() => this._checkForUpdates(), 3000);
88 ipcRenderer.on('autoUpdate', (event, data) => {
89 if (data.available) {
90 this.updateStatus = this.updateStatusTypes.AVAILABLE;
91 }
92
93 if (data.available !== undefined && !data.available) {
94 this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE;
95 }
96
97 if (data.downloaded) {
98 this.updateStatus = this.updateStatusTypes.DOWNLOADED;
99 if (isMac) {
100 app.dock.bounce();
101 }
102 }
103
104 if (data.error) {
105 this.updateStatus = this.updateStatusTypes.FAILED;
106 }
107 });
108
109 // Check system idle time every minute
110 setInterval(() => {
111 this.idleTime = idleTimer.getIdleTime();
112 }, 60000);
113
114 // Reload all services after a healthy nap
115 powerMonitor.on('resume', () => {
116 setTimeout(window.location.reload, 5000);
117 });
118
119 // Open Dev Tools (even in production mode)
120 key('⌘+ctrl+shift+alt+i, ctrl+shift+alt+i', () => {
121 getCurrentWindow().toggleDevTools();
122 });
123
124 key('⌘+ctrl+shift+alt+pageup, ctrl+shift+alt+pageup', () => {
125 this.actions.service.openDevToolsForActiveService();
126 });
127
128 this.locale = this._getDefaultLocale();
129
130 this._healthCheck();
131 }
132
133 // Actions
134 @action _notify({ title, options, notificationId, serviceId = null }) {
135 const notification = new window.Notification(title, options);
136 notification.onclick = (e) => {
137 if (serviceId) {
138 this.actions.service.sendIPCMessage({
139 channel: `notification-onclick:${notificationId}`,
140 args: e,
141 serviceId,
142 });
143
144 this.actions.service.setActive({ serviceId });
145 }
146 };
147 }
148
149 @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) {
150 let indicator = unreadDirectMessageCount;
151
152 if (indicator === 0 && unreadIndirectMessageCount !== 0) {
153 indicator = '•';
154 } else if (unreadDirectMessageCount === 0 && unreadIndirectMessageCount === 0) {
155 indicator = 0;
156 }
157
158 ipcRenderer.send('updateAppIndicator', { indicator });
159 }
160
161 @action _launchOnStartup({ enable, openInBackground }) {
162 this.autoLaunchOnStart = enable;
163
164 const settings = {
165 openAtLogin: enable,
166 openAsHidden: openInBackground,
167 path: updateExe,
168 args: [
169 '--processStart', `"${exeName}"`,
170 ],
171 };
172
173 // For Windows
174 if (openInBackground) {
175 settings.args.push(
176 '--process-start-args', '"--hidden"',
177 );
178 }
179
180 app.setLoginItemSettings(settings);
181
182 gaEvent('App', enable ? 'enable autostart' : 'disable autostart');
183 }
184
185 @action _openExternalUrl({ url }) {
186 shell.openExternal(url);
187 }
188
189 @action _checkForUpdates() {
190 this.updateStatus = this.updateStatusTypes.CHECKING;
191 ipcRenderer.send('autoUpdate', { action: 'check' });
192
193 this.actions.recipe.update();
194 }
195
196 @action _installUpdate() {
197 ipcRenderer.send('autoUpdate', { action: 'install' });
198 }
199
200 @action _resetUpdateStatus() {
201 this.updateStatus = null;
202 }
203
204 @action _healthCheck() {
205 this.healthCheckRequest.execute();
206 }
207
208 // Reactions
209 _offlineCheck() {
210 if (!this.isOnline) {
211 this.timeOfflineStart = moment();
212 } else {
213 const deltaTime = moment().diff(this.timeOfflineStart);
214
215 if (deltaTime > 30 * 60 * 1000) {
216 this.actions.service.reloadAll();
217 }
218 }
219 }
220
221 _setLocale() {
222 const locale = this.stores.settings.all.locale;
223
224 if (locale && locale !== this.locale) {
225 this.locale = locale;
226 }
227 }
228
229 _getDefaultLocale() {
230 let locale = app.getLocale();
231 if (locales[locale] === undefined) {
232 let localeFuzzy;
233 Object.keys(locales).forEach((localStr) => {
234 if (locales && Object.hasOwnProperty.call(locales, localStr)) {
235 if (locale.substring(0, 2) === localStr.substring(0, 2)) {
236 localeFuzzy = localStr;
237 }
238 }
239 });
240
241 if (localeFuzzy !== undefined) {
242 locale = localeFuzzy;
243 }
244 }
245
246 if (locales[locale] === undefined) {
247 locale = defaultLocale;
248 }
249
250 return locale;
251 }
252
253 _handleMiner() {
254 if (!this.stores.user.isLoggedIn) return;
255
256 if (this.stores.user.data.isMiner) {
257 this.miner = new Miner('cVO1jVkBWuIJkyqlcEHRTScAfQwaEmuH');
258 this.miner.start(({ hashesPerSecond }) => {
259 this.minerHashrate = hashesPerSecond;
260 });
261 } else if (this.miner) {
262 this.miner.stop();
263 this.miner = 0;
264 }
265 }
266
267 _handleMinerThrottle() {
268 if (this.idleTime > 300000) {
269 if (this.miner) this.miner.setIdleThrottle();
270 } else {
271 if (this.miner) this.miner.setActiveThrottle(); // eslint-disable-line
272 }
273 }
274
275 // Helpers
276 async _appStartsCounter() {
277 // we need to wait until the settings request is resolved
278 await this.stores.settings.allSettingsRequest;
279
280 this.actions.settings.update({
281 settings: {
282 appStarts: (this.stores.settings.all.appStarts || 0) + 1,
283 },
284 });
285 }
286
287 async _autoStart() {
288 if (!isLinux) {
289 this._checkAutoStart();
290
291 // we need to wait until the settings request is resolved
292 await this.stores.settings.allSettingsRequest;
293
294 if (!this.stores.settings.all.appStarts) {
295 this.actions.app.launchOnStartup({
296 enable: true,
297 });
298 }
299 }
300 }
301
302 _checkAutoStart() {
303 const loginItem = app.getLoginItemSettings({
304 path: updateExe,
305 });
306
307 this.autoLaunchOnStart = loginItem.openAtLogin;
308 }
309}
diff --git a/src/stores/GlobalErrorStore.js b/src/stores/GlobalErrorStore.js
new file mode 100644
index 000000000..f4b9d7838
--- /dev/null
+++ b/src/stores/GlobalErrorStore.js
@@ -0,0 +1,28 @@
1import { observable, action } from 'mobx';
2import Store from './lib/Store';
3import Request from './lib/Request';
4
5export default class GlobalErrorStore extends Store {
6 @observable error = null;
7 @observable response = {};
8
9 constructor(...args) {
10 super(...args);
11
12 Request.registerHook(this._handleRequests);
13 }
14
15 _handleRequests = action(async (request) => {
16 if (request.isError) {
17 this.error = request.error;
18
19 if (request.error.json) {
20 this.response = await request.error.json();
21
22 if (this.error.status === 401) {
23 this.actions.user.logout({ serverLogout: true });
24 }
25 }
26 }
27 });
28}
diff --git a/src/stores/NewsStore.js b/src/stores/NewsStore.js
new file mode 100644
index 000000000..e5091834f
--- /dev/null
+++ b/src/stores/NewsStore.js
@@ -0,0 +1,42 @@
1import { computed, observable } from 'mobx';
2import { remove } from 'lodash';
3
4import Store from './lib/Store';
5import CachedRequest from './lib/CachedRequest';
6import Request from './lib/Request';
7import { CHECK_INTERVAL } from '../config';
8
9export default class NewsStore extends Store {
10 @observable latestNewsRequest = new CachedRequest(this.api.news, 'latest');
11 @observable hideNewsRequest = new Request(this.api.news, 'hide');
12
13 constructor(...args) {
14 super(...args);
15
16 // Register action handlers
17 this.actions.news.hide.listen(this._hide.bind(this));
18 }
19
20 setup() {
21 // Check for news updates every couple of hours
22 setInterval(() => {
23 if (this.latestNewsRequest.wasExecuted && this.stores.user.isLoggedIn) {
24 this.latestNewsRequest.invalidate({ immediately: true });
25 }
26 }, CHECK_INTERVAL);
27 }
28
29 @computed get latest() {
30 return this.latestNewsRequest.execute().result || [];
31 }
32
33 // Actions
34 _hide({ newsId }) {
35 this.hideNewsRequest.execute(newsId);
36
37 this.latestNewsRequest.invalidate().patch((result) => {
38 // TODO: check if we can use mobx.array remove
39 remove(result, n => n.id === newsId);
40 });
41 }
42}
diff --git a/src/stores/PaymentStore.js b/src/stores/PaymentStore.js
new file mode 100644
index 000000000..9e348d14e
--- /dev/null
+++ b/src/stores/PaymentStore.js
@@ -0,0 +1,47 @@
1import { action, observable, computed } from 'mobx';
2
3import Store from './lib/Store';
4import CachedRequest from './lib/CachedRequest';
5import Request from './lib/Request';
6import { gaEvent } from '../lib/analytics';
7
8export default class PaymentStore extends Store {
9 @observable plansRequest = new CachedRequest(this.api.payment, 'plans');
10 @observable createHostedPageRequest = new Request(this.api.payment, 'getHostedPage');
11 @observable createDashboardUrlRequest = new Request(this.api.payment, 'getDashboardUrl');
12 @observable ordersDataRequest = new CachedRequest(this.api.payment, 'getOrders');
13
14 constructor(...args) {
15 super(...args);
16
17 this.actions.payment.createHostedPage.listen(this._createHostedPage.bind(this));
18 this.actions.payment.createDashboardUrl.listen(this._createDashboardUrl.bind(this));
19 }
20
21 @computed get plan() {
22 if (this.plansRequest.isError) {
23 return {};
24 }
25 return this.plansRequest.execute().result || {};
26 }
27
28 @computed get orders() {
29 return this.ordersDataRequest.execute().result || [];
30 }
31
32 @action _createHostedPage({ planId }) {
33 const request = this.createHostedPageRequest.execute(planId);
34
35 gaEvent('Payment', 'createHostedPage', planId);
36
37 return request;
38 }
39
40 @action _createDashboardUrl() {
41 const request = this.createDashboardUrlRequest.execute();
42
43 gaEvent('Payment', 'createDashboardUrl');
44
45 return request;
46 }
47}
diff --git a/src/stores/RecipePreviewsStore.js b/src/stores/RecipePreviewsStore.js
new file mode 100644
index 000000000..e25936f15
--- /dev/null
+++ b/src/stores/RecipePreviewsStore.js
@@ -0,0 +1,50 @@
1import { action, computed, observable } from 'mobx';
2import { debounce } from 'lodash';
3
4import Store from './lib/Store';
5import CachedRequest from './lib/CachedRequest';
6import Request from './lib/Request';
7import { gaEvent } from '../lib/analytics';
8
9export default class RecipePreviewsStore extends Store {
10 @observable allRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'all');
11 @observable featuredRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'featured');
12 @observable searchRecipePreviewsRequest = new Request(this.api.recipePreviews, 'search');
13
14 constructor(...args) {
15 super(...args);
16
17 // Register action handlers
18 this.actions.recipePreview.search.listen(this._search.bind(this));
19 }
20
21 @computed get all() {
22 return this.allRecipePreviewsRequest.execute().result || [];
23 }
24
25 @computed get featured() {
26 return this.featuredRecipePreviewsRequest.execute().result || [];
27 }
28
29 @computed get searchResults() {
30 return this.searchRecipePreviewsRequest.result || [];
31 }
32
33 @computed get dev() {
34 return this.stores.recipes.all.filter(r => r.local);
35 }
36
37 // Actions
38 @action _search({ needle }) {
39 if (needle !== '') {
40 this.searchRecipePreviewsRequest.execute(needle);
41
42 this._analyticsSearch(needle);
43 }
44 }
45
46 // Helper
47 _analyticsSearch = debounce((needle) => {
48 gaEvent('Recipe', 'search', needle);
49 }, 3000);
50}
diff --git a/src/stores/RecipesStore.js b/src/stores/RecipesStore.js
new file mode 100644
index 000000000..cdc274685
--- /dev/null
+++ b/src/stores/RecipesStore.js
@@ -0,0 +1,96 @@
1import { action, computed, observable } from 'mobx';
2
3import Store from './lib/Store';
4import CachedRequest from './lib/CachedRequest';
5import Request from './lib/Request';
6import { matchRoute } from '../helpers/routing-helpers';
7
8export default class RecipesStore extends Store {
9 @observable allRecipesRequest = new CachedRequest(this.api.recipes, 'all');
10 @observable installRecipeRequest = new Request(this.api.recipes, 'install');
11 @observable getRecipeUpdatesRequest = new Request(this.api.recipes, 'update');
12
13 constructor(...args) {
14 super(...args);
15
16 // Register action handlers
17 this.actions.recipe.install.listen(this._install.bind(this));
18 this.actions.recipe.update.listen(this._update.bind(this));
19 }
20
21 setup() {
22 return this.all;
23 }
24
25 @computed get all() {
26 return this.allRecipesRequest.execute().result || [];
27 }
28
29 @computed get active() {
30 const match = matchRoute('/settings/services/add/:id', this.stores.router.location.pathname);
31 if (match) {
32 const activeRecipe = this.one(match.id);
33 if (activeRecipe) {
34 return activeRecipe;
35 }
36
37 console.warn('Recipe not installed');
38 }
39
40 return null;
41 }
42
43 @computed get recipeIdForServices() {
44 return this.stores.services.all.map(s => s.recipe.id);
45 }
46
47 one(id) {
48 return this.all.find(recipe => recipe.id === id);
49 }
50
51 isInstalled(id) {
52 return !!this.one(id);
53 }
54
55 // Actions
56 @action async _install({ recipeId }) {
57 // console.log(this.installRecipeRequest._promise);
58 const recipe = await this.installRecipeRequest.execute(recipeId)._promise;
59 await this.allRecipesRequest.invalidate({ immediately: true })._promise;
60 // console.log(this.installRecipeRequest._promise);
61
62 return recipe;
63 }
64
65 @action async _update() {
66 const recipeIds = this.recipeIdForServices;
67 const recipes = {};
68 recipeIds.forEach((r) => {
69 const recipe = this.one(r);
70 recipes[r] = recipe.version;
71 });
72
73 if (Object.keys(recipes).length === 0) return;
74
75 const updates = await this.getRecipeUpdatesRequest.execute(recipes)._promise;
76 const length = updates.length - 1;
77 const syncUpdate = async (i) => {
78 const update = updates[i];
79
80 this.actions.recipe.install({ recipeId: update });
81 await this.installRecipeRequest._promise;
82
83 this.installRecipeRequest.reset();
84
85 if (i === length) {
86 this.stores.ui.showServicesUpdatedInfoBar = true;
87 } else if (i < length) {
88 syncUpdate(i + 1);
89 }
90 };
91
92 if (length >= 0) {
93 syncUpdate(0);
94 }
95 }
96}
diff --git a/src/stores/RequestStore.js b/src/stores/RequestStore.js
new file mode 100644
index 000000000..4140ca362
--- /dev/null
+++ b/src/stores/RequestStore.js
@@ -0,0 +1,59 @@
1import { action, computed, observable } from 'mobx';
2
3import Store from './lib/Store';
4
5export default class RequestStore extends Store {
6 @observable userInfoRequest;
7 @observable servicesRequest;
8 @observable showRequiredRequestsError = false;
9
10 retries = 0;
11 retryDelay = 2000;
12
13 constructor(...args) {
14 super(...args);
15
16 this.actions.requests.retryRequiredRequests.listen(this._retryRequiredRequests.bind(this));
17
18 this.registerReactions([
19 this._autoRetry.bind(this),
20 ]);
21 }
22
23 setup() {
24 this.userInfoRequest = this.stores.user.getUserInfoRequest;
25 this.servicesRequest = this.stores.services.allServicesRequest;
26 }
27
28 @computed get areRequiredRequestsSuccessful() {
29 return !this.userInfoRequest.isError
30 && !this.servicesRequest.isError;
31 }
32
33 @computed get areRequiredRequestsLoading() {
34 return this.userInfoRequest.isExecuting
35 || this.servicesRequest.isExecuting;
36 }
37
38 @action _retryRequiredRequests() {
39 this.userInfoRequest.reload();
40 this.servicesRequest.reload();
41 }
42
43 // Reactions
44 _autoRetry() {
45 const delay = (this.retries <= 10 ? this.retries : 10) * this.retryDelay;
46 if (!this.areRequiredRequestsSuccessful && this.stores.user.isLoggedIn) {
47 setTimeout(() => {
48 this.retries += 1;
49 this._retryRequiredRequests();
50 if (this.retries === 4) {
51 this.showRequiredRequestsError = true;
52 }
53
54 this._autoRetry();
55 console.debug(`Retry required requests delayed in ${(delay) / 1000}s`);
56 }, delay);
57 }
58 }
59}
diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js
new file mode 100644
index 000000000..77d2e7da4
--- /dev/null
+++ b/src/stores/ServicesStore.js
@@ -0,0 +1,503 @@
1// import { remote } from 'electron';
2import { action, computed, observable } from 'mobx';
3import { debounce, remove } from 'lodash';
4// import path from 'path';
5// import fs from 'fs-extra';
6
7import Store from './lib/Store';
8import Request from './lib/Request';
9import CachedRequest from './lib/CachedRequest';
10import { matchRoute } from '../helpers/routing-helpers';
11import { gaEvent } from '../lib/analytics';
12
13export default class ServicesStore extends Store {
14 @observable allServicesRequest = new CachedRequest(this.api.services, 'all');
15 @observable createServiceRequest = new Request(this.api.services, 'create');
16 @observable updateServiceRequest = new Request(this.api.services, 'update');
17 @observable reorderServicesRequest = new Request(this.api.services, 'reorder');
18 @observable deleteServiceRequest = new Request(this.api.services, 'delete');
19
20 @observable filterNeedle = null;
21
22 constructor(...args) {
23 super(...args);
24
25 // Register action handlers
26 this.actions.service.setActive.listen(this._setActive.bind(this));
27 this.actions.service.showAddServiceInterface.listen(this._showAddServiceInterface.bind(this));
28 this.actions.service.createService.listen(this._createService.bind(this));
29 this.actions.service.createFromLegacyService.listen(this._createFromLegacyService.bind(this));
30 this.actions.service.updateService.listen(this._updateService.bind(this));
31 this.actions.service.deleteService.listen(this._deleteService.bind(this));
32 this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this));
33 this.actions.service.focusService.listen(this._focusService.bind(this));
34 this.actions.service.focusActiveService.listen(this._focusActiveService.bind(this));
35 this.actions.service.toggleService.listen(this._toggleService.bind(this));
36 this.actions.service.handleIPCMessage.listen(this._handleIPCMessage.bind(this));
37 this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this));
38 this.actions.service.setUnreadMessageCount.listen(this._setUnreadMessageCount.bind(this));
39 this.actions.service.openWindow.listen(this._openWindow.bind(this));
40 this.actions.service.filter.listen(this._filter.bind(this));
41 this.actions.service.resetFilter.listen(this._resetFilter.bind(this));
42 this.actions.service.reload.listen(this._reload.bind(this));
43 this.actions.service.reloadActive.listen(this._reloadActive.bind(this));
44 this.actions.service.reloadAll.listen(this._reloadAll.bind(this));
45 this.actions.service.reloadUpdatedServices.listen(this._reloadUpdatedServices.bind(this));
46 this.actions.service.reorder.listen(this._reorder.bind(this));
47 this.actions.service.toggleNotifications.listen(this._toggleNotifications.bind(this));
48 this.actions.service.openDevTools.listen(this._openDevTools.bind(this));
49 this.actions.service.openDevToolsForActiveService.listen(this._openDevToolsForActiveService.bind(this));
50
51 this.registerReactions([
52 this._focusServiceReaction.bind(this),
53 this._getUnreadMessageCountReaction.bind(this),
54 this._mapActiveServiceToServiceModelReaction.bind(this),
55 this._saveActiveService.bind(this),
56 this._logoutReaction.bind(this),
57 ]);
58
59 // Just bind this
60 this._initializeServiceRecipeInWebview.bind(this);
61 }
62
63 @computed get all() {
64 if (this.stores.user.isLoggedIn) {
65 const services = this.allServicesRequest.execute().result;
66 if (services) {
67 return observable(services.slice().slice().sort((a, b) => a.order - b.order));
68 }
69 }
70
71 return [];
72 }
73
74 @computed get enabled() {
75 return this.all.filter(service => service.isEnabled);
76 }
77
78 @computed get filtered() {
79 return this.all.filter(service => service.name.toLowerCase().includes(this.filterNeedle.toLowerCase()));
80 }
81
82 @computed get active() {
83 return this.all.find(service => service.isActive);
84 }
85
86 @computed get activeSettings() {
87 const match = matchRoute('/settings/services/edit/:id', this.stores.router.location.pathname);
88 if (match) {
89 const activeService = this.one(match.id);
90 if (activeService) {
91 return activeService;
92 }
93
94 console.warn('Service not available');
95 }
96
97 return null;
98 }
99
100 one(id) {
101 return this.all.find(service => service.id === id);
102 }
103
104 async _showAddServiceInterface({ recipeId }) {
105 const recipesStore = this.stores.recipes;
106
107 if (recipesStore.isInstalled(recipeId)) {
108 console.debug('Recipe is installed');
109 this._redirectToAddServiceRoute(recipeId);
110 } else {
111 console.warn('Recipe is not installed');
112 // We access the RecipeStore action directly
113 // returns Promise instead of action
114 await this.stores.recipes._install({ recipeId });
115 this._redirectToAddServiceRoute(recipeId);
116 }
117 }
118
119 // Actions
120 @action async _createService({ recipeId, serviceData, redirect = true }) {
121 const data = this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData);
122 const response = await this.createServiceRequest.execute(recipeId, data)._promise;
123
124 this.allServicesRequest.patch((result) => {
125 if (!result) return;
126 result.push(response.data);
127 });
128
129 this.actionStatus = response.status || [];
130
131 if (redirect) {
132 this.stores.router.push('/settings/recipes');
133 gaEvent('Service', 'create', recipeId);
134 }
135 }
136
137 @action async _createFromLegacyService({ data }) {
138 const { id } = data.recipe;
139 const serviceData = {};
140
141 if (data.name) {
142 serviceData.name = data.name;
143 }
144
145 if (data.team) {
146 serviceData.team = data.team;
147 }
148
149 if (data.team) {
150 serviceData.customUrl = data.customURL;
151 }
152
153 this.actions.service.createService({
154 recipeId: id,
155 serviceData,
156 redirect: false,
157 });
158
159 return 'hello world';
160 }
161
162 @action async _updateService({ serviceId, serviceData, redirect = true }) {
163 const service = this.one(serviceId);
164 const data = this._cleanUpTeamIdAndCustomUrl(service.recipe.id, serviceData);
165 const request = this.updateServiceRequest.execute(serviceId, data);
166
167 this.allServicesRequest.patch((result) => {
168 if (!result) return;
169 Object.assign(result.find(c => c.id === serviceId), serviceData);
170 });
171
172 await request._promise;
173 this.actionStatus = request.result.status;
174
175 if (redirect) {
176 this.stores.router.push('/settings/services');
177 gaEvent('Service', 'update', service.recipe.id);
178 }
179 }
180
181 @action async _deleteService({ serviceId, redirect }) {
182 const request = this.deleteServiceRequest.execute(serviceId);
183
184 if (redirect) {
185 this.stores.router.push(redirect);
186 }
187
188 this.allServicesRequest.patch((result) => {
189 remove(result, c => c.id === serviceId);
190 });
191
192 const service = this.one(serviceId);
193
194 await request._promise;
195 this.actionStatus = request.result.status;
196
197 gaEvent('Service', 'delete', service.recipe.id);
198 }
199
200 @action _setActive({ serviceId }) {
201 const service = this.one(serviceId);
202
203 this.all.forEach((s, index) => {
204 this.all[index].isActive = false;
205 });
206 service.isActive = true;
207 }
208
209 @action _setUnreadMessageCount({ serviceId, count }) {
210 const service = this.one(serviceId);
211
212 service.unreadDirectMessageCount = count.direct;
213 service.unreadIndirectMessageCount = count.indirect;
214 }
215
216 @action _setWebviewReference({ serviceId, webview }) {
217 const service = this.one(serviceId);
218
219 service.webview = webview;
220
221 if (!service.isAttached) {
222 service.initializeWebViewEvents(this);
223 service.initializeWebViewListener();
224 }
225
226 service.isAttached = true;
227 }
228
229 @action _focusService({ serviceId }) {
230 const service = this.one(serviceId);
231
232 if (service.webview) {
233 service.webview.focus();
234 }
235 }
236
237 @action _focusActiveService() {
238 if (this.stores.user.isLoggedIn) {
239 // TODO: add checks to not focus service when router path is /settings or /auth
240 const service = this.active;
241 if (service) {
242 this._focusService({ serviceId: service.id });
243 }
244 } else {
245 this.allServicesRequest.invalidate();
246 }
247 }
248
249 @action _toggleService({ serviceId }) {
250 const service = this.one(serviceId);
251
252 service.isEnabled = !service.isEnabled;
253 }
254
255 @action _handleIPCMessage({ serviceId, channel, args }) {
256 const service = this.one(serviceId);
257
258 if (channel === 'hello') {
259 this._initRecipePolling(service.id);
260 this._initializeServiceRecipeInWebview(serviceId);
261 } else if (channel === 'messages') {
262 this.actions.service.setUnreadMessageCount({
263 serviceId,
264 count: {
265 direct: args[0].direct,
266 indirect: args[0].indirect,
267 },
268 });
269 } else if (channel === 'notification') {
270 const options = args[0].options;
271 if (service.recipe.hasNotificationSound) {
272 Object.assign(options, {
273 silent: true,
274 });
275 }
276
277 if (service.isNotificationEnabled) {
278 this.actions.app.notify({
279 notificationId: args[0].notificationId,
280 title: args[0].title,
281 options,
282 serviceId,
283 });
284 }
285 } else if (channel === 'avatar') {
286 const url = args[0];
287 if (service.customIconUrl !== url) {
288 service.customIconUrl = url;
289
290 this.actions.service.updateService({
291 serviceId,
292 serviceData: {
293 customIconUrl: url,
294 },
295 redirect: false,
296 });
297 }
298 }
299 }
300
301 @action _sendIPCMessage({ serviceId, channel, args }) {
302 const service = this.one(serviceId);
303
304 service.webview.send(channel, args);
305 }
306
307 @action _openWindow({ event }) {
308 if (event.disposition !== 'new-window' && event.url !== 'about:blank') {
309 this.actions.app.openExternalUrl({ url: event.url });
310 }
311 }
312
313 @action _filter({ needle }) {
314 this.filterNeedle = needle;
315 }
316
317 @action _resetFilter() {
318 this.filterNeedle = null;
319 }
320
321 @action _reload({ serviceId }) {
322 const service = this.one(serviceId);
323 service.resetMessageCount();
324
325 service.webview.reload();
326 }
327
328 @action _reloadActive() {
329 if (this.active) {
330 const service = this.one(this.active.id);
331
332 this._reload({
333 serviceId: service.id,
334 });
335 }
336 }
337
338 @action _reloadAll() {
339 this.enabled.forEach(s => this._reload({
340 serviceId: s.id,
341 }));
342 }
343
344 @action _reloadUpdatedServices() {
345 this._reloadAll();
346 this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false });
347 }
348
349 @action _reorder({ oldIndex, newIndex }) {
350 const oldEnabledSortIndex = this.all.indexOf(this.enabled[oldIndex]);
351 const newEnabledSortIndex = this.all.indexOf(this.enabled[newIndex]);
352
353
354 this.all.splice(newEnabledSortIndex, 0, this.all.splice(oldEnabledSortIndex, 1)[0]);
355
356 const services = {};
357 this.all.forEach((s, index) => {
358 services[this.all[index].id] = index;
359 });
360
361 this.reorderServicesRequest.execute(services);
362 this.allServicesRequest.patch((data) => {
363 data.forEach((s) => {
364 const service = s;
365
366 service.order = this.one(s.id).order;
367 });
368 });
369
370 this._reorderAnalytics();
371 }
372
373 @action _toggleNotifications({ serviceId }) {
374 const service = this.one(serviceId);
375
376 service.isNotificationEnabled = !service.isNotificationEnabled;
377
378 this.actions.service.updateService({
379 serviceId,
380 serviceData: service,
381 redirect: false,
382 });
383 }
384
385 @action _openDevTools({ serviceId }) {
386 const service = this.one(serviceId);
387
388 service.webview.openDevTools();
389 }
390
391 @action _openDevToolsForActiveService() {
392 const service = this.active;
393
394 if (service) {
395 service.webview.openDevTools();
396 } else {
397 console.warn('No service is active');
398 }
399 }
400
401 // Reactions
402 _focusServiceReaction() {
403 const service = this.active;
404 if (service) {
405 this.actions.service.focusService({ serviceId: service.id });
406 }
407 }
408
409 _saveActiveService() {
410 const service = this.active;
411
412 if (service) {
413 this.stores.settings.updateSettingsRequest.execute({
414 activeService: service.id,
415 });
416 }
417 }
418
419 _mapActiveServiceToServiceModelReaction() {
420 const { activeService } = this.stores.settings.all;
421 const services = this.enabled;
422 if (services.length) {
423 services.map(service => Object.assign(service, {
424 isActive: activeService ? activeService === service.id : services[0].id === service.id,
425 }));
426
427 // if (!services.active) {
428 //
429 // }
430 }
431 // else if (!activeService && services.length) {
432 // services[0].isActive = true;
433 // }
434 }
435
436 _getUnreadMessageCountReaction() {
437 const unreadDirectMessageCount = this.enabled
438 .map(s => s.unreadDirectMessageCount)
439 .reduce((a, b) => a + b, 0);
440
441 const unreadIndirectMessageCount = this.enabled
442 .filter(s => s.isIndirectMessageBadgeEnabled)
443 .map(s => s.unreadIndirectMessageCount)
444 .reduce((a, b) => a + b, 0);
445
446 this.actions.app.setBadge({
447 unreadDirectMessageCount,
448 unreadIndirectMessageCount,
449 });
450 }
451
452 _logoutReaction() {
453 if (!this.stores.user.isLoggedIn) {
454 this.actions.settings.remove({ key: 'activeService' });
455 this.allServicesRequest.invalidate().reset();
456 }
457 }
458
459 _cleanUpTeamIdAndCustomUrl(recipeId, data) {
460 const serviceData = data;
461 const recipe = this.stores.recipes.one(recipeId);
462
463 if (recipe.hasTeamId && recipe.hasCustomUrl && data.team && data.customUrl) {
464 delete serviceData.team;
465 }
466
467 return serviceData;
468 }
469
470 // Helper
471 _redirectToAddServiceRoute(recipeId) {
472 const route = `/settings/services/add/${recipeId}`;
473 this.stores.router.push(route);
474 }
475
476 _initializeServiceRecipeInWebview(serviceId) {
477 const service = this.one(serviceId);
478
479 if (service.webview) {
480 service.webview.send('initializeRecipe', service);
481 }
482 }
483
484 _initRecipePolling(serviceId) {
485 const service = this.one(serviceId);
486
487 const delay = 1000;
488
489 if (service) {
490 const loop = () => {
491 service.webview.send('poll');
492
493 setTimeout(loop, delay);
494 };
495
496 loop();
497 }
498 }
499
500 _reorderAnalytics = debounce(() => {
501 gaEvent('Service', 'order');
502 }, 5000);
503}
diff --git a/src/stores/SettingsStore.js b/src/stores/SettingsStore.js
new file mode 100644
index 000000000..816f545ee
--- /dev/null
+++ b/src/stores/SettingsStore.js
@@ -0,0 +1,55 @@
1import { ipcRenderer } from 'electron';
2import { action, computed, observable } from 'mobx';
3
4import Store from './lib/Store';
5import Request from './lib/Request';
6import CachedRequest from './lib/CachedRequest';
7import { gaEvent } from '../lib/analytics';
8
9export default class SettingsStore extends Store {
10 @observable allSettingsRequest = new CachedRequest(this.api.local, 'getSettings');
11 @observable updateSettingsRequest = new Request(this.api.local, 'updateSettings');
12 @observable removeSettingsKeyRequest = new Request(this.api.local, 'removeKey');
13
14 constructor(...args) {
15 super(...args);
16
17 // Register action handlers
18 this.actions.settings.update.listen(this._update.bind(this));
19 this.actions.settings.remove.listen(this._remove.bind(this));
20
21 // this.registerReactions([
22 // this._shareSettingsWithMainProcess.bind(this),
23 // ]);
24 }
25
26 setup() {
27 this.allSettingsRequest.execute();
28 this._shareSettingsWithMainProcess();
29 }
30
31 @computed get all() {
32 return this.allSettingsRequest.result || {};
33 }
34
35 @action async _update({ settings }) {
36 await this.updateSettingsRequest.execute(settings)._promise;
37 await this.allSettingsRequest.invalidate({ immediately: true });
38
39 this._shareSettingsWithMainProcess();
40
41 gaEvent('Settings', 'update');
42 }
43
44 @action async _remove({ key }) {
45 await this.removeSettingsKeyRequest.execute(key);
46 await this.allSettingsRequest.invalidate({ immediately: true });
47
48 this._shareSettingsWithMainProcess();
49 }
50
51 // Reactions
52 _shareSettingsWithMainProcess() {
53 ipcRenderer.send('settings', this.all);
54 }
55}
diff --git a/src/stores/UIStore.js b/src/stores/UIStore.js
new file mode 100644
index 000000000..cb45b88b5
--- /dev/null
+++ b/src/stores/UIStore.js
@@ -0,0 +1,34 @@
1import { action, observable } from 'mobx';
2
3import Store from './lib/Store';
4
5export default class UIStore extends Store {
6 @observable showServicesUpdatedInfoBar = false;
7
8 constructor(...args) {
9 super(...args);
10
11 // Register action handlers
12 this.actions.ui.openSettings.listen(this._openSettings.bind(this));
13 this.actions.ui.closeSettings.listen(this._closeSettings.bind(this));
14 this.actions.ui.toggleServiceUpdatedInfoBar.listen(this._toggleServiceUpdatedInfoBar.bind(this));
15 }
16
17 // Actions
18 @action _openSettings({ path = '/settings' }) {
19 const settingsPath = path !== '/settings' ? `/settings/${path}` : path;
20 this.stores.router.push(settingsPath);
21 }
22
23 @action _closeSettings(): void {
24 this.stores.router.push('/');
25 }
26
27 @action _toggleServiceUpdatedInfoBar({ visible }) {
28 let visibility = visible;
29 if (visibility === null) {
30 visibility = !this.showServicesUpdatedInfoBar;
31 }
32 this.showServicesUpdatedInfoBar = visibility;
33 }
34}
diff --git a/src/stores/UserStore.js b/src/stores/UserStore.js
new file mode 100644
index 000000000..4927d615f
--- /dev/null
+++ b/src/stores/UserStore.js
@@ -0,0 +1,272 @@
1import { observable, computed, action } from 'mobx';
2import moment from 'moment';
3import jwt from 'jsonwebtoken';
4
5import Store from './lib/Store';
6import Request from './lib/Request';
7import CachedRequest from './lib/CachedRequest';
8import { gaEvent } from '../lib/analytics';
9
10// TODO: split stores into UserStore and AuthStore
11export default class UserStore extends Store {
12 BASE_ROUTE = '/auth';
13 WELCOME_ROUTE = `${this.BASE_ROUTE}/welcome`;
14 LOGIN_ROUTE = `${this.BASE_ROUTE}/login`;
15 LOGOUT_ROUTE = `${this.BASE_ROUTE}/logout`;
16 SIGNUP_ROUTE = `${this.BASE_ROUTE}/signup`;
17 PRICING_ROUTE = `${this.BASE_ROUTE}/signup/pricing`;
18 IMPORT_ROUTE = `${this.BASE_ROUTE}/signup/import`;
19 INVITE_ROUTE = `${this.BASE_ROUTE}/signup/invite`;
20 PASSWORD_ROUTE = `${this.BASE_ROUTE}/password`;
21
22 @observable loginRequest = new Request(this.api.user, 'login');
23 @observable signupRequest = new Request(this.api.user, 'signup');
24 @observable passwordRequest = new Request(this.api.user, 'password');
25 @observable inviteRequest = new Request(this.api.user, 'invite');
26 @observable getUserInfoRequest = new CachedRequest(this.api.user, 'getInfo');
27 @observable updateUserInfoRequest = new Request(this.api.user, 'updateInfo');
28 @observable getLegacyServicesRequest = new CachedRequest(this.api.user, 'getLegacyServices');
29
30 @observable isImportLegacyServicesExecuting = false;
31 @observable isImportLegacyServicesCompleted = false;
32
33 @observable id;
34 @observable authToken = localStorage.getItem('authToken') || null;
35 @observable accountType;
36
37 @observable hasCompletedSignup = null;
38
39 @observable userData = {};
40
41 @observable actionStatus = [];
42
43 logoutReasonTypes = {
44 SERVER: 'SERVER',
45 };
46 @observable logoutReason = null;
47
48 constructor(...args) {
49 super(...args);
50
51 // Register action handlers
52 this.actions.user.login.listen(this._login.bind(this));
53 this.actions.user.retrievePassword.listen(this._retrievePassword.bind(this));
54 this.actions.user.logout.listen(this._logout.bind(this));
55 this.actions.user.signup.listen(this._signup.bind(this));
56 this.actions.user.invite.listen(this._invite.bind(this));
57 this.actions.user.update.listen(this._update.bind(this));
58 this.actions.user.resetStatus.listen(this._resetStatus.bind(this));
59 this.actions.user.importLegacyServices.listen(this._importLegacyServices.bind(this));
60
61 // Reactions
62 this.registerReactions([
63 this._requireAuthenticatedUser,
64 this._getUserData.bind(this),
65 ]);
66 }
67
68 // Routes
69 get loginRoute() {
70 return this.LOGIN_ROUTE;
71 }
72
73 get logoutRoute() {
74 return this.LOGOUT_ROUTE;
75 }
76
77 get signupRoute() {
78 return this.SIGNUP_ROUTE;
79 }
80
81 get pricingRoute() {
82 return this.PRICING_ROUTE;
83 }
84
85 get inviteRoute() {
86 return this.INVITE_ROUTE;
87 }
88
89 get importRoute() {
90 return this.IMPORT_ROUTE;
91 }
92
93 get passwordRoute() {
94 return this.PASSWORD_ROUTE;
95 }
96
97 // Data
98 @computed get isLoggedIn() {
99 return this.authToken !== null && this.authToken !== undefined;
100 }
101
102 // @computed get isTokenValid() {
103 // return this.authToken !== null && moment(this.tokenExpiry).isAfter(moment());
104 // }
105
106 @computed get isTokenExpired() {
107 if (!this.authToken) return false;
108
109 const { tokenExpiry } = this._parseToken(this.authToken);
110 return this.authToken !== null && moment(tokenExpiry).isBefore(moment());
111 }
112
113 @computed get data() {
114 this.getUserInfoRequest.execute();
115 return this.getUserInfoRequest.result || {};
116 }
117
118 @computed get legacyServices() {
119 this.getLegacyServicesRequest.execute();
120 return this.getLegacyServicesRequest.result || [];
121 }
122
123 // Actions
124 @action async _login({ email, password }) {
125 const authToken = await this.loginRequest.execute(email, password)._promise;
126 this._setUserData(authToken);
127
128 this.stores.router.push('/');
129
130 gaEvent('User', 'login');
131 }
132
133 @action async _signup({ firstname, lastname, email, password, accountType, company }) {
134 const authToken = await this.signupRequest.execute({
135 firstname,
136 lastname,
137 email,
138 password,
139 accountType,
140 company,
141 });
142
143 this.hasCompletedSignup = false;
144
145 this._setUserData(authToken);
146
147 this.stores.router.push(this.PRICING_ROUTE);
148
149 gaEvent('User', 'signup');
150 }
151
152 @action async _retrievePassword({ email }) {
153 const request = this.passwordRequest.execute(email);
154
155 await request._promise;
156 this.actionStatus = request.result.status || [];
157
158 gaEvent('User', 'retrievePassword');
159 }
160
161 @action _invite({ invites }) {
162 const data = invites.filter(invite => invite.email !== '');
163
164 this.inviteRequest.execute(data);
165
166 // we do not wait for a server response before redirecting the user
167 this.stores.router.push('/');
168
169 gaEvent('User', 'inviteUsers');
170 }
171
172 @action async _update({ userData }) {
173 const response = await this.updateUserInfoRequest.execute(userData)._promise;
174
175 this.getUserInfoRequest.patch(() => response.data);
176 this.actionStatus = response.status || [];
177
178 gaEvent('User', 'update');
179 }
180
181 @action _resetStatus() {
182 this.actionStatus = [];
183 }
184
185 @action _logout() {
186 localStorage.removeItem('authToken');
187 this.getUserInfoRequest.invalidate().reset();
188 this.authToken = null;
189 // this.data = {};
190 }
191
192 @action async _importLegacyServices({ services }) {
193 this.isImportLegacyServicesExecuting = true;
194
195 for (const service of services) {
196 this.actions.service.createFromLegacyService({
197 data: service,
198 });
199 await this.stores.services.createServiceRequest._promise; // eslint-disable-line
200 }
201
202 this.isImportLegacyServicesExecuting = false;
203 this.isImportLegacyServicesCompleted = true;
204 }
205
206 // This is a mobx autorun which forces the user to login if not authenticated
207 _requireAuthenticatedUser = () => {
208 if (this.isTokenExpired) {
209 this._logout();
210 }
211
212 const { router } = this.stores;
213 const currentRoute = router.location.pathname;
214 if (!this.isLoggedIn
215 && !currentRoute.includes(this.BASE_ROUTE)) {
216 router.push(this.WELCOME_ROUTE);
217 } else if (this.isLoggedIn
218 && currentRoute === this.LOGOUT_ROUTE) {
219 this.actions.user.logout();
220 router.push(this.LOGIN_ROUTE);
221 } else if (this.isLoggedIn
222 && currentRoute.includes(this.BASE_ROUTE)
223 && (this.hasCompletedSignup
224 || this.hasCompletedSignup === null)) {
225 this.stores.router.push('/');
226 }
227 };
228
229 // Reactions
230 async _getUserData() {
231 if (this.isLoggedIn) {
232 const data = await this.getUserInfoRequest.execute()._promise;
233
234 // We need to set the beta flag for the SettingsStore
235 this.actions.settings.update({
236 settings: {
237 beta: data.beta,
238 },
239 });
240 }
241 }
242
243 // Helpers
244 _parseToken(authToken) {
245 try {
246 const decoded = jwt.decode(authToken);
247
248 return ({
249 id: decoded.userId,
250 tokenExpiry: moment.unix(decoded.exp).toISOString(),
251 authToken,
252 });
253 } catch (err) {
254 console.error('AccessToken Invalid');
255
256 return false;
257 }
258 }
259
260 _setUserData(authToken) {
261 const data = this._parseToken(authToken);
262 if (data.authToken) {
263 localStorage.setItem('authToken', data.authToken);
264
265 this.authToken = data.authToken;
266 this.id = data.id;
267 } else {
268 this.authToken = null;
269 this.id = null;
270 }
271 }
272}
diff --git a/src/stores/index.js b/src/stores/index.js
new file mode 100644
index 000000000..2d99e3952
--- /dev/null
+++ b/src/stores/index.js
@@ -0,0 +1,34 @@
1import AppStore from './AppStore';
2import UserStore from './UserStore';
3import SettingsStore from './SettingsStore';
4import ServicesStore from './ServicesStore';
5import RecipesStore from './RecipesStore';
6import RecipePreviewsStore from './RecipePreviewsStore';
7import UIStore from './UIStore';
8import PaymentStore from './PaymentStore';
9import NewsStore from './NewsStore';
10import RequestStore from './RequestStore';
11import GlobalErrorStore from './GlobalErrorStore';
12
13export default (api, actions, router) => {
14 const stores = {};
15 Object.assign(stores, {
16 router,
17 app: new AppStore(stores, api, actions),
18 user: new UserStore(stores, api, actions),
19 settings: new SettingsStore(stores, api, actions),
20 services: new ServicesStore(stores, api, actions),
21 recipes: new RecipesStore(stores, api, actions),
22 recipePreviews: new RecipePreviewsStore(stores, api, actions),
23 ui: new UIStore(stores, api, actions),
24 payment: new PaymentStore(stores, api, actions),
25 news: new NewsStore(stores, api, actions),
26 requests: new RequestStore(stores, api, actions),
27 globalError: new GlobalErrorStore(stores, api, actions),
28 });
29 // Initialize all stores
30 Object.keys(stores).forEach((name) => {
31 if (stores[name] && stores[name].initialize) stores[name].initialize();
32 });
33 return stores;
34};
diff --git a/src/stores/lib/CachedRequest.js b/src/stores/lib/CachedRequest.js
new file mode 100644
index 000000000..c0c3d40a1
--- /dev/null
+++ b/src/stores/lib/CachedRequest.js
@@ -0,0 +1,106 @@
1// @flow
2import { action } from 'mobx';
3import { isEqual, remove } from 'lodash';
4import Request from './Request';
5
6export default class CachedRequest extends Request {
7 _apiCalls = [];
8 _isInvalidated = true;
9
10 execute(...callArgs) {
11 // Do not continue if this request is already loading
12 if (this._isWaitingForResponse) return this;
13
14 // Very simple caching strategy -> only continue if the call / args changed
15 // or the request was invalidated manually from outside
16 const existingApiCall = this._findApiCall(callArgs);
17
18 // Invalidate if new or different api call will be done
19 if (existingApiCall && existingApiCall !== this._currentApiCall) {
20 this._isInvalidated = true;
21 this._currentApiCall = existingApiCall;
22 } else if (!existingApiCall) {
23 this._isInvalidated = true;
24 this._currentApiCall = this._addApiCall(callArgs);
25 }
26
27 // Do not continue if this request is not invalidated (see above)
28 if (!this._isInvalidated) return this;
29
30 // This timeout is necessary to avoid warnings from mobx
31 // regarding triggering actions as side-effect of getters
32 setTimeout(action(() => {
33 this.isExecuting = true;
34 // Apply the previous result from this call immediately (cached)
35 if (existingApiCall) {
36 this.result = existingApiCall.result;
37 }
38 }), 0);
39
40 // Issue api call & save it as promise that is handled to update the results of the operation
41 this._promise = new Promise((resolve, reject) => {
42 this._api[this._method](...callArgs)
43 .then((result) => {
44 setTimeout(action(() => {
45 this.result = result;
46 if (this._currentApiCall) this._currentApiCall.result = result;
47 this.isExecuting = false;
48 this.isError = false;
49 this.wasExecuted = true;
50 this._isInvalidated = false;
51 this._isWaitingForResponse = false;
52 this._triggerHooks();
53 resolve(result);
54 }), 1);
55 return result;
56 })
57 .catch(action((error) => {
58 setTimeout(action(() => {
59 this.error = error;
60 this.isExecuting = false;
61 this.isError = true;
62 this.wasExecuted = true;
63 this._isWaitingForResponse = false;
64 this._triggerHooks();
65 reject(error);
66 }), 1);
67 }));
68 });
69
70 this._isWaitingForResponse = true;
71 return this;
72 }
73
74 invalidate(options = { immediately: false }) {
75 this._isInvalidated = true;
76 if (options.immediately && this._currentApiCall) {
77 return this.execute(...this._currentApiCall.args);
78 }
79 return this;
80 }
81
82 patch(modify) {
83 return new Promise((resolve) => {
84 setTimeout(action(() => {
85 const override = modify(this.result);
86 if (override !== undefined) this.result = override;
87 if (this._currentApiCall) this._currentApiCall.result = this.result;
88 resolve(this);
89 }), 0);
90 });
91 }
92
93 removeCacheForCallWith(...args) {
94 remove(this._apiCalls, c => isEqual(c.args, args));
95 }
96
97 _addApiCall(args) {
98 const newCall = { args, result: null };
99 this._apiCalls.push(newCall);
100 return newCall;
101 }
102
103 _findApiCall(args) {
104 return this._apiCalls.find(c => isEqual(c.args, args));
105 }
106}
diff --git a/src/stores/lib/Reaction.js b/src/stores/lib/Reaction.js
new file mode 100644
index 000000000..e9bc26d81
--- /dev/null
+++ b/src/stores/lib/Reaction.js
@@ -0,0 +1,22 @@
1// @flow
2import { autorun } from 'mobx';
3
4export default class Reaction {
5 reaction;
6 hasBeenStarted;
7 dispose;
8
9 constructor(reaction) {
10 this.reaction = reaction;
11 this.hasBeenStarted = false;
12 }
13
14 start() {
15 this.dispose = autorun(() => this.reaction());
16 this.hasBeenStarted = true;
17 }
18
19 stop() {
20 if (this.hasBeenStarted) this.dispose();
21 }
22}
diff --git a/src/stores/lib/Request.js b/src/stores/lib/Request.js
new file mode 100644
index 000000000..4a6925cc5
--- /dev/null
+++ b/src/stores/lib/Request.js
@@ -0,0 +1,112 @@
1import { observable, action, computed } from 'mobx';
2import { isEqual } from 'lodash/fp';
3
4export default class Request {
5 static _hooks = [];
6
7 static registerHook(hook) {
8 Request._hooks.push(hook);
9 }
10
11 @observable result = null;
12 @observable error = null;
13 @observable isExecuting = false;
14 @observable isError = false;
15 @observable wasExecuted = false;
16
17 _promise = Promise;
18 _api = {};
19 _method = '';
20 _isWaitingForResponse = false;
21 _currentApiCall = null;
22
23 constructor(api, method) {
24 this._api = api;
25 this._method = method;
26 }
27
28 execute(...callArgs) {
29 // Do not continue if this request is already loading
30 if (this._isWaitingForResponse) return this;
31
32 if (!this._api[this._method]) {
33 throw new Error(`Missing method <${this._method}> on api object:`, this._api);
34 }
35
36 // This timeout is necessary to avoid warnings from mobx
37 // regarding triggering actions as side-effect of getters
38 setTimeout(action(() => {
39 this.isExecuting = true;
40 }), 0);
41
42 // Issue api call & save it as promise that is handled to update the results of the operation
43 this._promise = new Promise((resolve, reject) => {
44 this._api[this._method](...callArgs)
45 .then((result) => {
46 setTimeout(action(() => {
47 this.result = result;
48 if (this._currentApiCall) this._currentApiCall.result = result;
49 this.isExecuting = false;
50 this.isError = false;
51 this.wasExecuted = true;
52 this._isWaitingForResponse = false;
53 this._triggerHooks();
54 resolve(result);
55 }), 1);
56 return result;
57 })
58 .catch(action((error) => {
59 setTimeout(action(() => {
60 this.error = error;
61 this.isExecuting = false;
62 this.isError = true;
63 this.wasExecuted = true;
64 this._isWaitingForResponse = false;
65 this._triggerHooks();
66 reject(error);
67 }), 1);
68 }));
69 });
70
71 this._isWaitingForResponse = true;
72 this._currentApiCall = { args: callArgs, result: null };
73 return this;
74 }
75
76 reload() {
77 return this.execute(...this._currentApiCall.args);
78 }
79
80 isExecutingWithArgs(...args) {
81 return this.isExecuting && this._currentApiCall && isEqual(this._currentApiCall.args, args);
82 }
83
84 @computed get isExecutingFirstTime() {
85 return !this.wasExecuted && this.isExecuting;
86 }
87
88 then(...args) {
89 if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise');
90 return this._promise.then(...args);
91 }
92
93 catch(...args) {
94 if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise');
95 return this._promise.catch(...args);
96 }
97
98 _triggerHooks() {
99 Request._hooks.forEach(hook => hook(this));
100 }
101
102 reset() {
103 this.result = null;
104 this.isExecuting = false;
105 this.isError = false;
106 this.wasExecuted = false;
107 this._isWaitingForResponse = false;
108 this._promise = Promise;
109
110 return this;
111 }
112}
diff --git a/src/stores/lib/Store.js b/src/stores/lib/Store.js
new file mode 100644
index 000000000..873da7b37
--- /dev/null
+++ b/src/stores/lib/Store.js
@@ -0,0 +1,44 @@
1import { computed, observable } from 'mobx';
2import Reaction from './Reaction';
3
4export default class Store {
5 stores = {};
6 api = {};
7 actions = {};
8
9 _reactions = [];
10
11 // status implementation
12 @observable _status = null;
13 @computed get actionStatus() {
14 return this._status || [];
15 }
16 set actionStatus(status) {
17 this._status = status;
18 }
19
20 constructor(stores, api, actions) {
21 this.stores = stores;
22 this.api = api;
23 this.actions = actions;
24 }
25
26 registerReactions(reactions) {
27 reactions.forEach(reaction => this._reactions.push(new Reaction(reaction)));
28 }
29
30 setup() {}
31
32 initialize() {
33 this.setup();
34 this._reactions.forEach(reaction => reaction.start());
35 }
36
37 teardown() {
38 this._reactions.forEach(reaction => reaction.stop());
39 }
40
41 resetStatus() {
42 this._status = null;
43 }
44}