diff options
author | Stefan Malzner <stefan@adlk.io> | 2017-10-13 12:29:40 +0200 |
---|---|---|
committer | Stefan Malzner <stefan@adlk.io> | 2017-10-13 12:29:40 +0200 |
commit | 58cda9cc7fb79ca9df6746de7f9662bc08dc156a (patch) | |
tree | 1211600c2a5d3b5f81c435c6896618111a611720 /src/stores | |
download | ferdium-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.js | 309 | ||||
-rw-r--r-- | src/stores/GlobalErrorStore.js | 28 | ||||
-rw-r--r-- | src/stores/NewsStore.js | 42 | ||||
-rw-r--r-- | src/stores/PaymentStore.js | 47 | ||||
-rw-r--r-- | src/stores/RecipePreviewsStore.js | 50 | ||||
-rw-r--r-- | src/stores/RecipesStore.js | 96 | ||||
-rw-r--r-- | src/stores/RequestStore.js | 59 | ||||
-rw-r--r-- | src/stores/ServicesStore.js | 503 | ||||
-rw-r--r-- | src/stores/SettingsStore.js | 55 | ||||
-rw-r--r-- | src/stores/UIStore.js | 34 | ||||
-rw-r--r-- | src/stores/UserStore.js | 272 | ||||
-rw-r--r-- | src/stores/index.js | 34 | ||||
-rw-r--r-- | src/stores/lib/CachedRequest.js | 106 | ||||
-rw-r--r-- | src/stores/lib/Reaction.js | 22 | ||||
-rw-r--r-- | src/stores/lib/Request.js | 112 | ||||
-rw-r--r-- | src/stores/lib/Store.js | 44 |
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 @@ | |||
1 | import { remote, ipcRenderer, shell } from 'electron'; | ||
2 | import { action, observable } from 'mobx'; | ||
3 | import moment from 'moment'; | ||
4 | import key from 'keymaster'; | ||
5 | import path from 'path'; | ||
6 | import idleTimer from '@paulcbetts/system-idle-time'; | ||
7 | |||
8 | import Store from './lib/Store'; | ||
9 | import Request from './lib/Request'; | ||
10 | import { CHECK_INTERVAL } from '../config'; | ||
11 | import { isMac, isLinux } from '../environment'; | ||
12 | import locales from '../i18n/translations'; | ||
13 | import { gaEvent } from '../lib/analytics'; | ||
14 | import Miner from '../lib/Miner'; | ||
15 | |||
16 | const { app, getCurrentWindow, powerMonitor } = remote; | ||
17 | const defaultLocale = 'en-US'; | ||
18 | |||
19 | const appFolder = path.dirname(process.execPath); | ||
20 | const updateExe = path.resolve(appFolder, '..', 'Update.exe'); | ||
21 | const exeName = path.basename(process.execPath); | ||
22 | |||
23 | export 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 @@ | |||
1 | import { observable, action } from 'mobx'; | ||
2 | import Store from './lib/Store'; | ||
3 | import Request from './lib/Request'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { computed, observable } from 'mobx'; | ||
2 | import { remove } from 'lodash'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import CachedRequest from './lib/CachedRequest'; | ||
6 | import Request from './lib/Request'; | ||
7 | import { CHECK_INTERVAL } from '../config'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, observable, computed } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | import CachedRequest from './lib/CachedRequest'; | ||
5 | import Request from './lib/Request'; | ||
6 | import { gaEvent } from '../lib/analytics'; | ||
7 | |||
8 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | import { debounce } from 'lodash'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import CachedRequest from './lib/CachedRequest'; | ||
6 | import Request from './lib/Request'; | ||
7 | import { gaEvent } from '../lib/analytics'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | import CachedRequest from './lib/CachedRequest'; | ||
5 | import Request from './lib/Request'; | ||
6 | import { matchRoute } from '../helpers/routing-helpers'; | ||
7 | |||
8 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | |||
5 | export 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'; | ||
2 | import { action, computed, observable } from 'mobx'; | ||
3 | import { debounce, remove } from 'lodash'; | ||
4 | // import path from 'path'; | ||
5 | // import fs from 'fs-extra'; | ||
6 | |||
7 | import Store from './lib/Store'; | ||
8 | import Request from './lib/Request'; | ||
9 | import CachedRequest from './lib/CachedRequest'; | ||
10 | import { matchRoute } from '../helpers/routing-helpers'; | ||
11 | import { gaEvent } from '../lib/analytics'; | ||
12 | |||
13 | export 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 @@ | |||
1 | import { ipcRenderer } from 'electron'; | ||
2 | import { action, computed, observable } from 'mobx'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import Request from './lib/Request'; | ||
6 | import CachedRequest from './lib/CachedRequest'; | ||
7 | import { gaEvent } from '../lib/analytics'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { observable, computed, action } from 'mobx'; | ||
2 | import moment from 'moment'; | ||
3 | import jwt from 'jsonwebtoken'; | ||
4 | |||
5 | import Store from './lib/Store'; | ||
6 | import Request from './lib/Request'; | ||
7 | import CachedRequest from './lib/CachedRequest'; | ||
8 | import { gaEvent } from '../lib/analytics'; | ||
9 | |||
10 | // TODO: split stores into UserStore and AuthStore | ||
11 | export 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 @@ | |||
1 | import AppStore from './AppStore'; | ||
2 | import UserStore from './UserStore'; | ||
3 | import SettingsStore from './SettingsStore'; | ||
4 | import ServicesStore from './ServicesStore'; | ||
5 | import RecipesStore from './RecipesStore'; | ||
6 | import RecipePreviewsStore from './RecipePreviewsStore'; | ||
7 | import UIStore from './UIStore'; | ||
8 | import PaymentStore from './PaymentStore'; | ||
9 | import NewsStore from './NewsStore'; | ||
10 | import RequestStore from './RequestStore'; | ||
11 | import GlobalErrorStore from './GlobalErrorStore'; | ||
12 | |||
13 | export 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 | ||
2 | import { action } from 'mobx'; | ||
3 | import { isEqual, remove } from 'lodash'; | ||
4 | import Request from './Request'; | ||
5 | |||
6 | export 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 | ||
2 | import { autorun } from 'mobx'; | ||
3 | |||
4 | export 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 @@ | |||
1 | import { observable, action, computed } from 'mobx'; | ||
2 | import { isEqual } from 'lodash/fp'; | ||
3 | |||
4 | export 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 @@ | |||
1 | import { computed, observable } from 'mobx'; | ||
2 | import Reaction from './Reaction'; | ||
3 | |||
4 | export 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 | } | ||