From 58cda9cc7fb79ca9df6746de7f9662bc08dc156a Mon Sep 17 00:00:00 2001 From: Stefan Malzner Date: Fri, 13 Oct 2017 12:29:40 +0200 Subject: initial commit --- src/stores/AppStore.js | 309 +++++++++++++++++++++++ src/stores/GlobalErrorStore.js | 28 +++ src/stores/NewsStore.js | 42 ++++ src/stores/PaymentStore.js | 47 ++++ src/stores/RecipePreviewsStore.js | 50 ++++ src/stores/RecipesStore.js | 96 ++++++++ src/stores/RequestStore.js | 59 +++++ src/stores/ServicesStore.js | 503 ++++++++++++++++++++++++++++++++++++++ src/stores/SettingsStore.js | 55 +++++ src/stores/UIStore.js | 34 +++ src/stores/UserStore.js | 272 +++++++++++++++++++++ src/stores/index.js | 34 +++ src/stores/lib/CachedRequest.js | 106 ++++++++ src/stores/lib/Reaction.js | 22 ++ src/stores/lib/Request.js | 112 +++++++++ src/stores/lib/Store.js | 44 ++++ 16 files changed, 1813 insertions(+) create mode 100644 src/stores/AppStore.js create mode 100644 src/stores/GlobalErrorStore.js create mode 100644 src/stores/NewsStore.js create mode 100644 src/stores/PaymentStore.js create mode 100644 src/stores/RecipePreviewsStore.js create mode 100644 src/stores/RecipesStore.js create mode 100644 src/stores/RequestStore.js create mode 100644 src/stores/ServicesStore.js create mode 100644 src/stores/SettingsStore.js create mode 100644 src/stores/UIStore.js create mode 100644 src/stores/UserStore.js create mode 100644 src/stores/index.js create mode 100644 src/stores/lib/CachedRequest.js create mode 100644 src/stores/lib/Reaction.js create mode 100644 src/stores/lib/Request.js create mode 100644 src/stores/lib/Store.js (limited to 'src/stores') 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 @@ +import { remote, ipcRenderer, shell } from 'electron'; +import { action, observable } from 'mobx'; +import moment from 'moment'; +import key from 'keymaster'; +import path from 'path'; +import idleTimer from '@paulcbetts/system-idle-time'; + +import Store from './lib/Store'; +import Request from './lib/Request'; +import { CHECK_INTERVAL } from '../config'; +import { isMac, isLinux } from '../environment'; +import locales from '../i18n/translations'; +import { gaEvent } from '../lib/analytics'; +import Miner from '../lib/Miner'; + +const { app, getCurrentWindow, powerMonitor } = remote; +const defaultLocale = 'en-US'; + +const appFolder = path.dirname(process.execPath); +const updateExe = path.resolve(appFolder, '..', 'Update.exe'); +const exeName = path.basename(process.execPath); + +export default class AppStore extends Store { + updateStatusTypes = { + CHECKING: 'CHECKING', + AVAILABLE: 'AVAILABLE', + NOT_AVAILABLE: 'NOT_AVAILABLE', + DOWNLOADED: 'DOWNLOADED', + FAILED: 'FAILED', + }; + + @observable healthCheckRequest = new Request(this.api.app, 'health'); + + @observable autoLaunchOnStart = true; + + @observable isOnline = navigator.onLine; + @observable timeOfflineStart; + + @observable updateStatus = null; + + @observable locale = defaultLocale; + + @observable idleTime = 0; + + miner = null; + @observable minerHashrate = 0.0; + + constructor(...args: any) { + super(...args); + + // Register action handlers + this.actions.app.notify.listen(this._notify.bind(this)); + this.actions.app.setBadge.listen(this._setBadge.bind(this)); + this.actions.app.launchOnStartup.listen(this._launchOnStartup.bind(this)); + this.actions.app.openExternalUrl.listen(this._openExternalUrl.bind(this)); + this.actions.app.checkForUpdates.listen(this._checkForUpdates.bind(this)); + this.actions.app.installUpdate.listen(this._installUpdate.bind(this)); + this.actions.app.resetUpdateStatus.listen(this._resetUpdateStatus.bind(this)); + this.actions.app.healthCheck.listen(this._healthCheck.bind(this)); + + this.registerReactions([ + this._offlineCheck.bind(this), + this._setLocale.bind(this), + this._handleMiner.bind(this), + this._handleMinerThrottle.bind(this), + ]); + } + + setup() { + this._appStartsCounter(); + // Focus the active service + window.addEventListener('focus', this.actions.service.focusActiveService); + + // Online/Offline handling + window.addEventListener('online', () => { this.isOnline = true; }); + window.addEventListener('offline', () => { this.isOnline = false; }); + + this.isOnline = navigator.onLine; + + // Check if Franz should launch on start + // Needs to be delayed a bit + this._autoStart(); + + // Check for updates once every 4 hours + setInterval(() => this._checkForUpdates(), CHECK_INTERVAL); + // Check for an update in 30s (need a delay to prevent Squirrel Installer lock file issues) + setTimeout(() => this._checkForUpdates(), 3000); + ipcRenderer.on('autoUpdate', (event, data) => { + if (data.available) { + this.updateStatus = this.updateStatusTypes.AVAILABLE; + } + + if (data.available !== undefined && !data.available) { + this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; + } + + if (data.downloaded) { + this.updateStatus = this.updateStatusTypes.DOWNLOADED; + if (isMac) { + app.dock.bounce(); + } + } + + if (data.error) { + this.updateStatus = this.updateStatusTypes.FAILED; + } + }); + + // Check system idle time every minute + setInterval(() => { + this.idleTime = idleTimer.getIdleTime(); + }, 60000); + + // Reload all services after a healthy nap + powerMonitor.on('resume', () => { + setTimeout(window.location.reload, 5000); + }); + + // Open Dev Tools (even in production mode) + key('⌘+ctrl+shift+alt+i, ctrl+shift+alt+i', () => { + getCurrentWindow().toggleDevTools(); + }); + + key('⌘+ctrl+shift+alt+pageup, ctrl+shift+alt+pageup', () => { + this.actions.service.openDevToolsForActiveService(); + }); + + this.locale = this._getDefaultLocale(); + + this._healthCheck(); + } + + // Actions + @action _notify({ title, options, notificationId, serviceId = null }) { + const notification = new window.Notification(title, options); + notification.onclick = (e) => { + if (serviceId) { + this.actions.service.sendIPCMessage({ + channel: `notification-onclick:${notificationId}`, + args: e, + serviceId, + }); + + this.actions.service.setActive({ serviceId }); + } + }; + } + + @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) { + let indicator = unreadDirectMessageCount; + + if (indicator === 0 && unreadIndirectMessageCount !== 0) { + indicator = '•'; + } else if (unreadDirectMessageCount === 0 && unreadIndirectMessageCount === 0) { + indicator = 0; + } + + ipcRenderer.send('updateAppIndicator', { indicator }); + } + + @action _launchOnStartup({ enable, openInBackground }) { + this.autoLaunchOnStart = enable; + + const settings = { + openAtLogin: enable, + openAsHidden: openInBackground, + path: updateExe, + args: [ + '--processStart', `"${exeName}"`, + ], + }; + + // For Windows + if (openInBackground) { + settings.args.push( + '--process-start-args', '"--hidden"', + ); + } + + app.setLoginItemSettings(settings); + + gaEvent('App', enable ? 'enable autostart' : 'disable autostart'); + } + + @action _openExternalUrl({ url }) { + shell.openExternal(url); + } + + @action _checkForUpdates() { + this.updateStatus = this.updateStatusTypes.CHECKING; + ipcRenderer.send('autoUpdate', { action: 'check' }); + + this.actions.recipe.update(); + } + + @action _installUpdate() { + ipcRenderer.send('autoUpdate', { action: 'install' }); + } + + @action _resetUpdateStatus() { + this.updateStatus = null; + } + + @action _healthCheck() { + this.healthCheckRequest.execute(); + } + + // Reactions + _offlineCheck() { + if (!this.isOnline) { + this.timeOfflineStart = moment(); + } else { + const deltaTime = moment().diff(this.timeOfflineStart); + + if (deltaTime > 30 * 60 * 1000) { + this.actions.service.reloadAll(); + } + } + } + + _setLocale() { + const locale = this.stores.settings.all.locale; + + if (locale && locale !== this.locale) { + this.locale = locale; + } + } + + _getDefaultLocale() { + let locale = app.getLocale(); + if (locales[locale] === undefined) { + let localeFuzzy; + Object.keys(locales).forEach((localStr) => { + if (locales && Object.hasOwnProperty.call(locales, localStr)) { + if (locale.substring(0, 2) === localStr.substring(0, 2)) { + localeFuzzy = localStr; + } + } + }); + + if (localeFuzzy !== undefined) { + locale = localeFuzzy; + } + } + + if (locales[locale] === undefined) { + locale = defaultLocale; + } + + return locale; + } + + _handleMiner() { + if (!this.stores.user.isLoggedIn) return; + + if (this.stores.user.data.isMiner) { + this.miner = new Miner('cVO1jVkBWuIJkyqlcEHRTScAfQwaEmuH'); + this.miner.start(({ hashesPerSecond }) => { + this.minerHashrate = hashesPerSecond; + }); + } else if (this.miner) { + this.miner.stop(); + this.miner = 0; + } + } + + _handleMinerThrottle() { + if (this.idleTime > 300000) { + if (this.miner) this.miner.setIdleThrottle(); + } else { + if (this.miner) this.miner.setActiveThrottle(); // eslint-disable-line + } + } + + // Helpers + async _appStartsCounter() { + // we need to wait until the settings request is resolved + await this.stores.settings.allSettingsRequest; + + this.actions.settings.update({ + settings: { + appStarts: (this.stores.settings.all.appStarts || 0) + 1, + }, + }); + } + + async _autoStart() { + if (!isLinux) { + this._checkAutoStart(); + + // we need to wait until the settings request is resolved + await this.stores.settings.allSettingsRequest; + + if (!this.stores.settings.all.appStarts) { + this.actions.app.launchOnStartup({ + enable: true, + }); + } + } + } + + _checkAutoStart() { + const loginItem = app.getLoginItemSettings({ + path: updateExe, + }); + + this.autoLaunchOnStart = loginItem.openAtLogin; + } +} 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 @@ +import { observable, action } from 'mobx'; +import Store from './lib/Store'; +import Request from './lib/Request'; + +export default class GlobalErrorStore extends Store { + @observable error = null; + @observable response = {}; + + constructor(...args) { + super(...args); + + Request.registerHook(this._handleRequests); + } + + _handleRequests = action(async (request) => { + if (request.isError) { + this.error = request.error; + + if (request.error.json) { + this.response = await request.error.json(); + + if (this.error.status === 401) { + this.actions.user.logout({ serverLogout: true }); + } + } + } + }); +} 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 @@ +import { computed, observable } from 'mobx'; +import { remove } from 'lodash'; + +import Store from './lib/Store'; +import CachedRequest from './lib/CachedRequest'; +import Request from './lib/Request'; +import { CHECK_INTERVAL } from '../config'; + +export default class NewsStore extends Store { + @observable latestNewsRequest = new CachedRequest(this.api.news, 'latest'); + @observable hideNewsRequest = new Request(this.api.news, 'hide'); + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.news.hide.listen(this._hide.bind(this)); + } + + setup() { + // Check for news updates every couple of hours + setInterval(() => { + if (this.latestNewsRequest.wasExecuted && this.stores.user.isLoggedIn) { + this.latestNewsRequest.invalidate({ immediately: true }); + } + }, CHECK_INTERVAL); + } + + @computed get latest() { + return this.latestNewsRequest.execute().result || []; + } + + // Actions + _hide({ newsId }) { + this.hideNewsRequest.execute(newsId); + + this.latestNewsRequest.invalidate().patch((result) => { + // TODO: check if we can use mobx.array remove + remove(result, n => n.id === newsId); + }); + } +} 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 @@ +import { action, observable, computed } from 'mobx'; + +import Store from './lib/Store'; +import CachedRequest from './lib/CachedRequest'; +import Request from './lib/Request'; +import { gaEvent } from '../lib/analytics'; + +export default class PaymentStore extends Store { + @observable plansRequest = new CachedRequest(this.api.payment, 'plans'); + @observable createHostedPageRequest = new Request(this.api.payment, 'getHostedPage'); + @observable createDashboardUrlRequest = new Request(this.api.payment, 'getDashboardUrl'); + @observable ordersDataRequest = new CachedRequest(this.api.payment, 'getOrders'); + + constructor(...args) { + super(...args); + + this.actions.payment.createHostedPage.listen(this._createHostedPage.bind(this)); + this.actions.payment.createDashboardUrl.listen(this._createDashboardUrl.bind(this)); + } + + @computed get plan() { + if (this.plansRequest.isError) { + return {}; + } + return this.plansRequest.execute().result || {}; + } + + @computed get orders() { + return this.ordersDataRequest.execute().result || []; + } + + @action _createHostedPage({ planId }) { + const request = this.createHostedPageRequest.execute(planId); + + gaEvent('Payment', 'createHostedPage', planId); + + return request; + } + + @action _createDashboardUrl() { + const request = this.createDashboardUrlRequest.execute(); + + gaEvent('Payment', 'createDashboardUrl'); + + return request; + } +} 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 @@ +import { action, computed, observable } from 'mobx'; +import { debounce } from 'lodash'; + +import Store from './lib/Store'; +import CachedRequest from './lib/CachedRequest'; +import Request from './lib/Request'; +import { gaEvent } from '../lib/analytics'; + +export default class RecipePreviewsStore extends Store { + @observable allRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'all'); + @observable featuredRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'featured'); + @observable searchRecipePreviewsRequest = new Request(this.api.recipePreviews, 'search'); + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.recipePreview.search.listen(this._search.bind(this)); + } + + @computed get all() { + return this.allRecipePreviewsRequest.execute().result || []; + } + + @computed get featured() { + return this.featuredRecipePreviewsRequest.execute().result || []; + } + + @computed get searchResults() { + return this.searchRecipePreviewsRequest.result || []; + } + + @computed get dev() { + return this.stores.recipes.all.filter(r => r.local); + } + + // Actions + @action _search({ needle }) { + if (needle !== '') { + this.searchRecipePreviewsRequest.execute(needle); + + this._analyticsSearch(needle); + } + } + + // Helper + _analyticsSearch = debounce((needle) => { + gaEvent('Recipe', 'search', needle); + }, 3000); +} 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 @@ +import { action, computed, observable } from 'mobx'; + +import Store from './lib/Store'; +import CachedRequest from './lib/CachedRequest'; +import Request from './lib/Request'; +import { matchRoute } from '../helpers/routing-helpers'; + +export default class RecipesStore extends Store { + @observable allRecipesRequest = new CachedRequest(this.api.recipes, 'all'); + @observable installRecipeRequest = new Request(this.api.recipes, 'install'); + @observable getRecipeUpdatesRequest = new Request(this.api.recipes, 'update'); + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.recipe.install.listen(this._install.bind(this)); + this.actions.recipe.update.listen(this._update.bind(this)); + } + + setup() { + return this.all; + } + + @computed get all() { + return this.allRecipesRequest.execute().result || []; + } + + @computed get active() { + const match = matchRoute('/settings/services/add/:id', this.stores.router.location.pathname); + if (match) { + const activeRecipe = this.one(match.id); + if (activeRecipe) { + return activeRecipe; + } + + console.warn('Recipe not installed'); + } + + return null; + } + + @computed get recipeIdForServices() { + return this.stores.services.all.map(s => s.recipe.id); + } + + one(id) { + return this.all.find(recipe => recipe.id === id); + } + + isInstalled(id) { + return !!this.one(id); + } + + // Actions + @action async _install({ recipeId }) { + // console.log(this.installRecipeRequest._promise); + const recipe = await this.installRecipeRequest.execute(recipeId)._promise; + await this.allRecipesRequest.invalidate({ immediately: true })._promise; + // console.log(this.installRecipeRequest._promise); + + return recipe; + } + + @action async _update() { + const recipeIds = this.recipeIdForServices; + const recipes = {}; + recipeIds.forEach((r) => { + const recipe = this.one(r); + recipes[r] = recipe.version; + }); + + if (Object.keys(recipes).length === 0) return; + + const updates = await this.getRecipeUpdatesRequest.execute(recipes)._promise; + const length = updates.length - 1; + const syncUpdate = async (i) => { + const update = updates[i]; + + this.actions.recipe.install({ recipeId: update }); + await this.installRecipeRequest._promise; + + this.installRecipeRequest.reset(); + + if (i === length) { + this.stores.ui.showServicesUpdatedInfoBar = true; + } else if (i < length) { + syncUpdate(i + 1); + } + }; + + if (length >= 0) { + syncUpdate(0); + } + } +} 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 @@ +import { action, computed, observable } from 'mobx'; + +import Store from './lib/Store'; + +export default class RequestStore extends Store { + @observable userInfoRequest; + @observable servicesRequest; + @observable showRequiredRequestsError = false; + + retries = 0; + retryDelay = 2000; + + constructor(...args) { + super(...args); + + this.actions.requests.retryRequiredRequests.listen(this._retryRequiredRequests.bind(this)); + + this.registerReactions([ + this._autoRetry.bind(this), + ]); + } + + setup() { + this.userInfoRequest = this.stores.user.getUserInfoRequest; + this.servicesRequest = this.stores.services.allServicesRequest; + } + + @computed get areRequiredRequestsSuccessful() { + return !this.userInfoRequest.isError + && !this.servicesRequest.isError; + } + + @computed get areRequiredRequestsLoading() { + return this.userInfoRequest.isExecuting + || this.servicesRequest.isExecuting; + } + + @action _retryRequiredRequests() { + this.userInfoRequest.reload(); + this.servicesRequest.reload(); + } + + // Reactions + _autoRetry() { + const delay = (this.retries <= 10 ? this.retries : 10) * this.retryDelay; + if (!this.areRequiredRequestsSuccessful && this.stores.user.isLoggedIn) { + setTimeout(() => { + this.retries += 1; + this._retryRequiredRequests(); + if (this.retries === 4) { + this.showRequiredRequestsError = true; + } + + this._autoRetry(); + console.debug(`Retry required requests delayed in ${(delay) / 1000}s`); + }, delay); + } + } +} 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 @@ +// import { remote } from 'electron'; +import { action, computed, observable } from 'mobx'; +import { debounce, remove } from 'lodash'; +// import path from 'path'; +// import fs from 'fs-extra'; + +import Store from './lib/Store'; +import Request from './lib/Request'; +import CachedRequest from './lib/CachedRequest'; +import { matchRoute } from '../helpers/routing-helpers'; +import { gaEvent } from '../lib/analytics'; + +export default class ServicesStore extends Store { + @observable allServicesRequest = new CachedRequest(this.api.services, 'all'); + @observable createServiceRequest = new Request(this.api.services, 'create'); + @observable updateServiceRequest = new Request(this.api.services, 'update'); + @observable reorderServicesRequest = new Request(this.api.services, 'reorder'); + @observable deleteServiceRequest = new Request(this.api.services, 'delete'); + + @observable filterNeedle = null; + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.service.setActive.listen(this._setActive.bind(this)); + this.actions.service.showAddServiceInterface.listen(this._showAddServiceInterface.bind(this)); + this.actions.service.createService.listen(this._createService.bind(this)); + this.actions.service.createFromLegacyService.listen(this._createFromLegacyService.bind(this)); + this.actions.service.updateService.listen(this._updateService.bind(this)); + this.actions.service.deleteService.listen(this._deleteService.bind(this)); + this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this)); + this.actions.service.focusService.listen(this._focusService.bind(this)); + this.actions.service.focusActiveService.listen(this._focusActiveService.bind(this)); + this.actions.service.toggleService.listen(this._toggleService.bind(this)); + this.actions.service.handleIPCMessage.listen(this._handleIPCMessage.bind(this)); + this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this)); + this.actions.service.setUnreadMessageCount.listen(this._setUnreadMessageCount.bind(this)); + this.actions.service.openWindow.listen(this._openWindow.bind(this)); + this.actions.service.filter.listen(this._filter.bind(this)); + this.actions.service.resetFilter.listen(this._resetFilter.bind(this)); + this.actions.service.reload.listen(this._reload.bind(this)); + this.actions.service.reloadActive.listen(this._reloadActive.bind(this)); + this.actions.service.reloadAll.listen(this._reloadAll.bind(this)); + this.actions.service.reloadUpdatedServices.listen(this._reloadUpdatedServices.bind(this)); + this.actions.service.reorder.listen(this._reorder.bind(this)); + this.actions.service.toggleNotifications.listen(this._toggleNotifications.bind(this)); + this.actions.service.openDevTools.listen(this._openDevTools.bind(this)); + this.actions.service.openDevToolsForActiveService.listen(this._openDevToolsForActiveService.bind(this)); + + this.registerReactions([ + this._focusServiceReaction.bind(this), + this._getUnreadMessageCountReaction.bind(this), + this._mapActiveServiceToServiceModelReaction.bind(this), + this._saveActiveService.bind(this), + this._logoutReaction.bind(this), + ]); + + // Just bind this + this._initializeServiceRecipeInWebview.bind(this); + } + + @computed get all() { + if (this.stores.user.isLoggedIn) { + const services = this.allServicesRequest.execute().result; + if (services) { + return observable(services.slice().slice().sort((a, b) => a.order - b.order)); + } + } + + return []; + } + + @computed get enabled() { + return this.all.filter(service => service.isEnabled); + } + + @computed get filtered() { + return this.all.filter(service => service.name.toLowerCase().includes(this.filterNeedle.toLowerCase())); + } + + @computed get active() { + return this.all.find(service => service.isActive); + } + + @computed get activeSettings() { + const match = matchRoute('/settings/services/edit/:id', this.stores.router.location.pathname); + if (match) { + const activeService = this.one(match.id); + if (activeService) { + return activeService; + } + + console.warn('Service not available'); + } + + return null; + } + + one(id) { + return this.all.find(service => service.id === id); + } + + async _showAddServiceInterface({ recipeId }) { + const recipesStore = this.stores.recipes; + + if (recipesStore.isInstalled(recipeId)) { + console.debug('Recipe is installed'); + this._redirectToAddServiceRoute(recipeId); + } else { + console.warn('Recipe is not installed'); + // We access the RecipeStore action directly + // returns Promise instead of action + await this.stores.recipes._install({ recipeId }); + this._redirectToAddServiceRoute(recipeId); + } + } + + // Actions + @action async _createService({ recipeId, serviceData, redirect = true }) { + const data = this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData); + const response = await this.createServiceRequest.execute(recipeId, data)._promise; + + this.allServicesRequest.patch((result) => { + if (!result) return; + result.push(response.data); + }); + + this.actionStatus = response.status || []; + + if (redirect) { + this.stores.router.push('/settings/recipes'); + gaEvent('Service', 'create', recipeId); + } + } + + @action async _createFromLegacyService({ data }) { + const { id } = data.recipe; + const serviceData = {}; + + if (data.name) { + serviceData.name = data.name; + } + + if (data.team) { + serviceData.team = data.team; + } + + if (data.team) { + serviceData.customUrl = data.customURL; + } + + this.actions.service.createService({ + recipeId: id, + serviceData, + redirect: false, + }); + + return 'hello world'; + } + + @action async _updateService({ serviceId, serviceData, redirect = true }) { + const service = this.one(serviceId); + const data = this._cleanUpTeamIdAndCustomUrl(service.recipe.id, serviceData); + const request = this.updateServiceRequest.execute(serviceId, data); + + this.allServicesRequest.patch((result) => { + if (!result) return; + Object.assign(result.find(c => c.id === serviceId), serviceData); + }); + + await request._promise; + this.actionStatus = request.result.status; + + if (redirect) { + this.stores.router.push('/settings/services'); + gaEvent('Service', 'update', service.recipe.id); + } + } + + @action async _deleteService({ serviceId, redirect }) { + const request = this.deleteServiceRequest.execute(serviceId); + + if (redirect) { + this.stores.router.push(redirect); + } + + this.allServicesRequest.patch((result) => { + remove(result, c => c.id === serviceId); + }); + + const service = this.one(serviceId); + + await request._promise; + this.actionStatus = request.result.status; + + gaEvent('Service', 'delete', service.recipe.id); + } + + @action _setActive({ serviceId }) { + const service = this.one(serviceId); + + this.all.forEach((s, index) => { + this.all[index].isActive = false; + }); + service.isActive = true; + } + + @action _setUnreadMessageCount({ serviceId, count }) { + const service = this.one(serviceId); + + service.unreadDirectMessageCount = count.direct; + service.unreadIndirectMessageCount = count.indirect; + } + + @action _setWebviewReference({ serviceId, webview }) { + const service = this.one(serviceId); + + service.webview = webview; + + if (!service.isAttached) { + service.initializeWebViewEvents(this); + service.initializeWebViewListener(); + } + + service.isAttached = true; + } + + @action _focusService({ serviceId }) { + const service = this.one(serviceId); + + if (service.webview) { + service.webview.focus(); + } + } + + @action _focusActiveService() { + if (this.stores.user.isLoggedIn) { + // TODO: add checks to not focus service when router path is /settings or /auth + const service = this.active; + if (service) { + this._focusService({ serviceId: service.id }); + } + } else { + this.allServicesRequest.invalidate(); + } + } + + @action _toggleService({ serviceId }) { + const service = this.one(serviceId); + + service.isEnabled = !service.isEnabled; + } + + @action _handleIPCMessage({ serviceId, channel, args }) { + const service = this.one(serviceId); + + if (channel === 'hello') { + this._initRecipePolling(service.id); + this._initializeServiceRecipeInWebview(serviceId); + } else if (channel === 'messages') { + this.actions.service.setUnreadMessageCount({ + serviceId, + count: { + direct: args[0].direct, + indirect: args[0].indirect, + }, + }); + } else if (channel === 'notification') { + const options = args[0].options; + if (service.recipe.hasNotificationSound) { + Object.assign(options, { + silent: true, + }); + } + + if (service.isNotificationEnabled) { + this.actions.app.notify({ + notificationId: args[0].notificationId, + title: args[0].title, + options, + serviceId, + }); + } + } else if (channel === 'avatar') { + const url = args[0]; + if (service.customIconUrl !== url) { + service.customIconUrl = url; + + this.actions.service.updateService({ + serviceId, + serviceData: { + customIconUrl: url, + }, + redirect: false, + }); + } + } + } + + @action _sendIPCMessage({ serviceId, channel, args }) { + const service = this.one(serviceId); + + service.webview.send(channel, args); + } + + @action _openWindow({ event }) { + if (event.disposition !== 'new-window' && event.url !== 'about:blank') { + this.actions.app.openExternalUrl({ url: event.url }); + } + } + + @action _filter({ needle }) { + this.filterNeedle = needle; + } + + @action _resetFilter() { + this.filterNeedle = null; + } + + @action _reload({ serviceId }) { + const service = this.one(serviceId); + service.resetMessageCount(); + + service.webview.reload(); + } + + @action _reloadActive() { + if (this.active) { + const service = this.one(this.active.id); + + this._reload({ + serviceId: service.id, + }); + } + } + + @action _reloadAll() { + this.enabled.forEach(s => this._reload({ + serviceId: s.id, + })); + } + + @action _reloadUpdatedServices() { + this._reloadAll(); + this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false }); + } + + @action _reorder({ oldIndex, newIndex }) { + const oldEnabledSortIndex = this.all.indexOf(this.enabled[oldIndex]); + const newEnabledSortIndex = this.all.indexOf(this.enabled[newIndex]); + + + this.all.splice(newEnabledSortIndex, 0, this.all.splice(oldEnabledSortIndex, 1)[0]); + + const services = {}; + this.all.forEach((s, index) => { + services[this.all[index].id] = index; + }); + + this.reorderServicesRequest.execute(services); + this.allServicesRequest.patch((data) => { + data.forEach((s) => { + const service = s; + + service.order = this.one(s.id).order; + }); + }); + + this._reorderAnalytics(); + } + + @action _toggleNotifications({ serviceId }) { + const service = this.one(serviceId); + + service.isNotificationEnabled = !service.isNotificationEnabled; + + this.actions.service.updateService({ + serviceId, + serviceData: service, + redirect: false, + }); + } + + @action _openDevTools({ serviceId }) { + const service = this.one(serviceId); + + service.webview.openDevTools(); + } + + @action _openDevToolsForActiveService() { + const service = this.active; + + if (service) { + service.webview.openDevTools(); + } else { + console.warn('No service is active'); + } + } + + // Reactions + _focusServiceReaction() { + const service = this.active; + if (service) { + this.actions.service.focusService({ serviceId: service.id }); + } + } + + _saveActiveService() { + const service = this.active; + + if (service) { + this.stores.settings.updateSettingsRequest.execute({ + activeService: service.id, + }); + } + } + + _mapActiveServiceToServiceModelReaction() { + const { activeService } = this.stores.settings.all; + const services = this.enabled; + if (services.length) { + services.map(service => Object.assign(service, { + isActive: activeService ? activeService === service.id : services[0].id === service.id, + })); + + // if (!services.active) { + // + // } + } + // else if (!activeService && services.length) { + // services[0].isActive = true; + // } + } + + _getUnreadMessageCountReaction() { + const unreadDirectMessageCount = this.enabled + .map(s => s.unreadDirectMessageCount) + .reduce((a, b) => a + b, 0); + + const unreadIndirectMessageCount = this.enabled + .filter(s => s.isIndirectMessageBadgeEnabled) + .map(s => s.unreadIndirectMessageCount) + .reduce((a, b) => a + b, 0); + + this.actions.app.setBadge({ + unreadDirectMessageCount, + unreadIndirectMessageCount, + }); + } + + _logoutReaction() { + if (!this.stores.user.isLoggedIn) { + this.actions.settings.remove({ key: 'activeService' }); + this.allServicesRequest.invalidate().reset(); + } + } + + _cleanUpTeamIdAndCustomUrl(recipeId, data) { + const serviceData = data; + const recipe = this.stores.recipes.one(recipeId); + + if (recipe.hasTeamId && recipe.hasCustomUrl && data.team && data.customUrl) { + delete serviceData.team; + } + + return serviceData; + } + + // Helper + _redirectToAddServiceRoute(recipeId) { + const route = `/settings/services/add/${recipeId}`; + this.stores.router.push(route); + } + + _initializeServiceRecipeInWebview(serviceId) { + const service = this.one(serviceId); + + if (service.webview) { + service.webview.send('initializeRecipe', service); + } + } + + _initRecipePolling(serviceId) { + const service = this.one(serviceId); + + const delay = 1000; + + if (service) { + const loop = () => { + service.webview.send('poll'); + + setTimeout(loop, delay); + }; + + loop(); + } + } + + _reorderAnalytics = debounce(() => { + gaEvent('Service', 'order'); + }, 5000); +} 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 @@ +import { ipcRenderer } from 'electron'; +import { action, computed, observable } from 'mobx'; + +import Store from './lib/Store'; +import Request from './lib/Request'; +import CachedRequest from './lib/CachedRequest'; +import { gaEvent } from '../lib/analytics'; + +export default class SettingsStore extends Store { + @observable allSettingsRequest = new CachedRequest(this.api.local, 'getSettings'); + @observable updateSettingsRequest = new Request(this.api.local, 'updateSettings'); + @observable removeSettingsKeyRequest = new Request(this.api.local, 'removeKey'); + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.settings.update.listen(this._update.bind(this)); + this.actions.settings.remove.listen(this._remove.bind(this)); + + // this.registerReactions([ + // this._shareSettingsWithMainProcess.bind(this), + // ]); + } + + setup() { + this.allSettingsRequest.execute(); + this._shareSettingsWithMainProcess(); + } + + @computed get all() { + return this.allSettingsRequest.result || {}; + } + + @action async _update({ settings }) { + await this.updateSettingsRequest.execute(settings)._promise; + await this.allSettingsRequest.invalidate({ immediately: true }); + + this._shareSettingsWithMainProcess(); + + gaEvent('Settings', 'update'); + } + + @action async _remove({ key }) { + await this.removeSettingsKeyRequest.execute(key); + await this.allSettingsRequest.invalidate({ immediately: true }); + + this._shareSettingsWithMainProcess(); + } + + // Reactions + _shareSettingsWithMainProcess() { + ipcRenderer.send('settings', this.all); + } +} 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 @@ +import { action, observable } from 'mobx'; + +import Store from './lib/Store'; + +export default class UIStore extends Store { + @observable showServicesUpdatedInfoBar = false; + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.ui.openSettings.listen(this._openSettings.bind(this)); + this.actions.ui.closeSettings.listen(this._closeSettings.bind(this)); + this.actions.ui.toggleServiceUpdatedInfoBar.listen(this._toggleServiceUpdatedInfoBar.bind(this)); + } + + // Actions + @action _openSettings({ path = '/settings' }) { + const settingsPath = path !== '/settings' ? `/settings/${path}` : path; + this.stores.router.push(settingsPath); + } + + @action _closeSettings(): void { + this.stores.router.push('/'); + } + + @action _toggleServiceUpdatedInfoBar({ visible }) { + let visibility = visible; + if (visibility === null) { + visibility = !this.showServicesUpdatedInfoBar; + } + this.showServicesUpdatedInfoBar = visibility; + } +} 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 @@ +import { observable, computed, action } from 'mobx'; +import moment from 'moment'; +import jwt from 'jsonwebtoken'; + +import Store from './lib/Store'; +import Request from './lib/Request'; +import CachedRequest from './lib/CachedRequest'; +import { gaEvent } from '../lib/analytics'; + +// TODO: split stores into UserStore and AuthStore +export default class UserStore extends Store { + BASE_ROUTE = '/auth'; + WELCOME_ROUTE = `${this.BASE_ROUTE}/welcome`; + LOGIN_ROUTE = `${this.BASE_ROUTE}/login`; + LOGOUT_ROUTE = `${this.BASE_ROUTE}/logout`; + SIGNUP_ROUTE = `${this.BASE_ROUTE}/signup`; + PRICING_ROUTE = `${this.BASE_ROUTE}/signup/pricing`; + IMPORT_ROUTE = `${this.BASE_ROUTE}/signup/import`; + INVITE_ROUTE = `${this.BASE_ROUTE}/signup/invite`; + PASSWORD_ROUTE = `${this.BASE_ROUTE}/password`; + + @observable loginRequest = new Request(this.api.user, 'login'); + @observable signupRequest = new Request(this.api.user, 'signup'); + @observable passwordRequest = new Request(this.api.user, 'password'); + @observable inviteRequest = new Request(this.api.user, 'invite'); + @observable getUserInfoRequest = new CachedRequest(this.api.user, 'getInfo'); + @observable updateUserInfoRequest = new Request(this.api.user, 'updateInfo'); + @observable getLegacyServicesRequest = new CachedRequest(this.api.user, 'getLegacyServices'); + + @observable isImportLegacyServicesExecuting = false; + @observable isImportLegacyServicesCompleted = false; + + @observable id; + @observable authToken = localStorage.getItem('authToken') || null; + @observable accountType; + + @observable hasCompletedSignup = null; + + @observable userData = {}; + + @observable actionStatus = []; + + logoutReasonTypes = { + SERVER: 'SERVER', + }; + @observable logoutReason = null; + + constructor(...args) { + super(...args); + + // Register action handlers + this.actions.user.login.listen(this._login.bind(this)); + this.actions.user.retrievePassword.listen(this._retrievePassword.bind(this)); + this.actions.user.logout.listen(this._logout.bind(this)); + this.actions.user.signup.listen(this._signup.bind(this)); + this.actions.user.invite.listen(this._invite.bind(this)); + this.actions.user.update.listen(this._update.bind(this)); + this.actions.user.resetStatus.listen(this._resetStatus.bind(this)); + this.actions.user.importLegacyServices.listen(this._importLegacyServices.bind(this)); + + // Reactions + this.registerReactions([ + this._requireAuthenticatedUser, + this._getUserData.bind(this), + ]); + } + + // Routes + get loginRoute() { + return this.LOGIN_ROUTE; + } + + get logoutRoute() { + return this.LOGOUT_ROUTE; + } + + get signupRoute() { + return this.SIGNUP_ROUTE; + } + + get pricingRoute() { + return this.PRICING_ROUTE; + } + + get inviteRoute() { + return this.INVITE_ROUTE; + } + + get importRoute() { + return this.IMPORT_ROUTE; + } + + get passwordRoute() { + return this.PASSWORD_ROUTE; + } + + // Data + @computed get isLoggedIn() { + return this.authToken !== null && this.authToken !== undefined; + } + + // @computed get isTokenValid() { + // return this.authToken !== null && moment(this.tokenExpiry).isAfter(moment()); + // } + + @computed get isTokenExpired() { + if (!this.authToken) return false; + + const { tokenExpiry } = this._parseToken(this.authToken); + return this.authToken !== null && moment(tokenExpiry).isBefore(moment()); + } + + @computed get data() { + this.getUserInfoRequest.execute(); + return this.getUserInfoRequest.result || {}; + } + + @computed get legacyServices() { + this.getLegacyServicesRequest.execute(); + return this.getLegacyServicesRequest.result || []; + } + + // Actions + @action async _login({ email, password }) { + const authToken = await this.loginRequest.execute(email, password)._promise; + this._setUserData(authToken); + + this.stores.router.push('/'); + + gaEvent('User', 'login'); + } + + @action async _signup({ firstname, lastname, email, password, accountType, company }) { + const authToken = await this.signupRequest.execute({ + firstname, + lastname, + email, + password, + accountType, + company, + }); + + this.hasCompletedSignup = false; + + this._setUserData(authToken); + + this.stores.router.push(this.PRICING_ROUTE); + + gaEvent('User', 'signup'); + } + + @action async _retrievePassword({ email }) { + const request = this.passwordRequest.execute(email); + + await request._promise; + this.actionStatus = request.result.status || []; + + gaEvent('User', 'retrievePassword'); + } + + @action _invite({ invites }) { + const data = invites.filter(invite => invite.email !== ''); + + this.inviteRequest.execute(data); + + // we do not wait for a server response before redirecting the user + this.stores.router.push('/'); + + gaEvent('User', 'inviteUsers'); + } + + @action async _update({ userData }) { + const response = await this.updateUserInfoRequest.execute(userData)._promise; + + this.getUserInfoRequest.patch(() => response.data); + this.actionStatus = response.status || []; + + gaEvent('User', 'update'); + } + + @action _resetStatus() { + this.actionStatus = []; + } + + @action _logout() { + localStorage.removeItem('authToken'); + this.getUserInfoRequest.invalidate().reset(); + this.authToken = null; + // this.data = {}; + } + + @action async _importLegacyServices({ services }) { + this.isImportLegacyServicesExecuting = true; + + for (const service of services) { + this.actions.service.createFromLegacyService({ + data: service, + }); + await this.stores.services.createServiceRequest._promise; // eslint-disable-line + } + + this.isImportLegacyServicesExecuting = false; + this.isImportLegacyServicesCompleted = true; + } + + // This is a mobx autorun which forces the user to login if not authenticated + _requireAuthenticatedUser = () => { + if (this.isTokenExpired) { + this._logout(); + } + + const { router } = this.stores; + const currentRoute = router.location.pathname; + if (!this.isLoggedIn + && !currentRoute.includes(this.BASE_ROUTE)) { + router.push(this.WELCOME_ROUTE); + } else if (this.isLoggedIn + && currentRoute === this.LOGOUT_ROUTE) { + this.actions.user.logout(); + router.push(this.LOGIN_ROUTE); + } else if (this.isLoggedIn + && currentRoute.includes(this.BASE_ROUTE) + && (this.hasCompletedSignup + || this.hasCompletedSignup === null)) { + this.stores.router.push('/'); + } + }; + + // Reactions + async _getUserData() { + if (this.isLoggedIn) { + const data = await this.getUserInfoRequest.execute()._promise; + + // We need to set the beta flag for the SettingsStore + this.actions.settings.update({ + settings: { + beta: data.beta, + }, + }); + } + } + + // Helpers + _parseToken(authToken) { + try { + const decoded = jwt.decode(authToken); + + return ({ + id: decoded.userId, + tokenExpiry: moment.unix(decoded.exp).toISOString(), + authToken, + }); + } catch (err) { + console.error('AccessToken Invalid'); + + return false; + } + } + + _setUserData(authToken) { + const data = this._parseToken(authToken); + if (data.authToken) { + localStorage.setItem('authToken', data.authToken); + + this.authToken = data.authToken; + this.id = data.id; + } else { + this.authToken = null; + this.id = null; + } + } +} 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 @@ +import AppStore from './AppStore'; +import UserStore from './UserStore'; +import SettingsStore from './SettingsStore'; +import ServicesStore from './ServicesStore'; +import RecipesStore from './RecipesStore'; +import RecipePreviewsStore from './RecipePreviewsStore'; +import UIStore from './UIStore'; +import PaymentStore from './PaymentStore'; +import NewsStore from './NewsStore'; +import RequestStore from './RequestStore'; +import GlobalErrorStore from './GlobalErrorStore'; + +export default (api, actions, router) => { + const stores = {}; + Object.assign(stores, { + router, + app: new AppStore(stores, api, actions), + user: new UserStore(stores, api, actions), + settings: new SettingsStore(stores, api, actions), + services: new ServicesStore(stores, api, actions), + recipes: new RecipesStore(stores, api, actions), + recipePreviews: new RecipePreviewsStore(stores, api, actions), + ui: new UIStore(stores, api, actions), + payment: new PaymentStore(stores, api, actions), + news: new NewsStore(stores, api, actions), + requests: new RequestStore(stores, api, actions), + globalError: new GlobalErrorStore(stores, api, actions), + }); + // Initialize all stores + Object.keys(stores).forEach((name) => { + if (stores[name] && stores[name].initialize) stores[name].initialize(); + }); + return stores; +}; 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 @@ +// @flow +import { action } from 'mobx'; +import { isEqual, remove } from 'lodash'; +import Request from './Request'; + +export default class CachedRequest extends Request { + _apiCalls = []; + _isInvalidated = true; + + execute(...callArgs) { + // Do not continue if this request is already loading + if (this._isWaitingForResponse) return this; + + // Very simple caching strategy -> only continue if the call / args changed + // or the request was invalidated manually from outside + const existingApiCall = this._findApiCall(callArgs); + + // Invalidate if new or different api call will be done + if (existingApiCall && existingApiCall !== this._currentApiCall) { + this._isInvalidated = true; + this._currentApiCall = existingApiCall; + } else if (!existingApiCall) { + this._isInvalidated = true; + this._currentApiCall = this._addApiCall(callArgs); + } + + // Do not continue if this request is not invalidated (see above) + if (!this._isInvalidated) return this; + + // This timeout is necessary to avoid warnings from mobx + // regarding triggering actions as side-effect of getters + setTimeout(action(() => { + this.isExecuting = true; + // Apply the previous result from this call immediately (cached) + if (existingApiCall) { + this.result = existingApiCall.result; + } + }), 0); + + // Issue api call & save it as promise that is handled to update the results of the operation + this._promise = new Promise((resolve, reject) => { + this._api[this._method](...callArgs) + .then((result) => { + setTimeout(action(() => { + this.result = result; + if (this._currentApiCall) this._currentApiCall.result = result; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = true; + this._isInvalidated = false; + this._isWaitingForResponse = false; + this._triggerHooks(); + resolve(result); + }), 1); + return result; + }) + .catch(action((error) => { + setTimeout(action(() => { + this.error = error; + this.isExecuting = false; + this.isError = true; + this.wasExecuted = true; + this._isWaitingForResponse = false; + this._triggerHooks(); + reject(error); + }), 1); + })); + }); + + this._isWaitingForResponse = true; + return this; + } + + invalidate(options = { immediately: false }) { + this._isInvalidated = true; + if (options.immediately && this._currentApiCall) { + return this.execute(...this._currentApiCall.args); + } + return this; + } + + patch(modify) { + return new Promise((resolve) => { + setTimeout(action(() => { + const override = modify(this.result); + if (override !== undefined) this.result = override; + if (this._currentApiCall) this._currentApiCall.result = this.result; + resolve(this); + }), 0); + }); + } + + removeCacheForCallWith(...args) { + remove(this._apiCalls, c => isEqual(c.args, args)); + } + + _addApiCall(args) { + const newCall = { args, result: null }; + this._apiCalls.push(newCall); + return newCall; + } + + _findApiCall(args) { + return this._apiCalls.find(c => isEqual(c.args, args)); + } +} 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 @@ +// @flow +import { autorun } from 'mobx'; + +export default class Reaction { + reaction; + hasBeenStarted; + dispose; + + constructor(reaction) { + this.reaction = reaction; + this.hasBeenStarted = false; + } + + start() { + this.dispose = autorun(() => this.reaction()); + this.hasBeenStarted = true; + } + + stop() { + if (this.hasBeenStarted) this.dispose(); + } +} 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 @@ +import { observable, action, computed } from 'mobx'; +import { isEqual } from 'lodash/fp'; + +export default class Request { + static _hooks = []; + + static registerHook(hook) { + Request._hooks.push(hook); + } + + @observable result = null; + @observable error = null; + @observable isExecuting = false; + @observable isError = false; + @observable wasExecuted = false; + + _promise = Promise; + _api = {}; + _method = ''; + _isWaitingForResponse = false; + _currentApiCall = null; + + constructor(api, method) { + this._api = api; + this._method = method; + } + + execute(...callArgs) { + // Do not continue if this request is already loading + if (this._isWaitingForResponse) return this; + + if (!this._api[this._method]) { + throw new Error(`Missing method <${this._method}> on api object:`, this._api); + } + + // This timeout is necessary to avoid warnings from mobx + // regarding triggering actions as side-effect of getters + setTimeout(action(() => { + this.isExecuting = true; + }), 0); + + // Issue api call & save it as promise that is handled to update the results of the operation + this._promise = new Promise((resolve, reject) => { + this._api[this._method](...callArgs) + .then((result) => { + setTimeout(action(() => { + this.result = result; + if (this._currentApiCall) this._currentApiCall.result = result; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = true; + this._isWaitingForResponse = false; + this._triggerHooks(); + resolve(result); + }), 1); + return result; + }) + .catch(action((error) => { + setTimeout(action(() => { + this.error = error; + this.isExecuting = false; + this.isError = true; + this.wasExecuted = true; + this._isWaitingForResponse = false; + this._triggerHooks(); + reject(error); + }), 1); + })); + }); + + this._isWaitingForResponse = true; + this._currentApiCall = { args: callArgs, result: null }; + return this; + } + + reload() { + return this.execute(...this._currentApiCall.args); + } + + isExecutingWithArgs(...args) { + return this.isExecuting && this._currentApiCall && isEqual(this._currentApiCall.args, args); + } + + @computed get isExecutingFirstTime() { + return !this.wasExecuted && this.isExecuting; + } + + then(...args) { + if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise'); + return this._promise.then(...args); + } + + catch(...args) { + if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise'); + return this._promise.catch(...args); + } + + _triggerHooks() { + Request._hooks.forEach(hook => hook(this)); + } + + reset() { + this.result = null; + this.isExecuting = false; + this.isError = false; + this.wasExecuted = false; + this._isWaitingForResponse = false; + this._promise = Promise; + + return this; + } +} 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 @@ +import { computed, observable } from 'mobx'; +import Reaction from './Reaction'; + +export default class Store { + stores = {}; + api = {}; + actions = {}; + + _reactions = []; + + // status implementation + @observable _status = null; + @computed get actionStatus() { + return this._status || []; + } + set actionStatus(status) { + this._status = status; + } + + constructor(stores, api, actions) { + this.stores = stores; + this.api = api; + this.actions = actions; + } + + registerReactions(reactions) { + reactions.forEach(reaction => this._reactions.push(new Reaction(reaction))); + } + + setup() {} + + initialize() { + this.setup(); + this._reactions.forEach(reaction => reaction.start()); + } + + teardown() { + this._reactions.forEach(reaction => reaction.stop()); + } + + resetStatus() { + this._status = null; + } +} -- cgit v1.2.3-54-g00ecf