From 73ba955e344c8ccedd43235495ef8b72b5a2b6fd Mon Sep 17 00:00:00 2001 From: Ricardo Cino Date: Wed, 22 Jun 2022 00:32:18 +0200 Subject: chore: Transform AppStore.js into Typescript (#329) * turn actions into typescript * correct tsconfig * added TypedStore --- .eslintrc.js | 2 + babel.config.json | 4 +- gulpfile.babel.js | 8 +- jest.config.js | 1 + src/I18n.tsx | 8 +- src/actions/app.ts | 3 +- src/actions/lib/actions.ts | 25 +- src/actions/recipe.ts | 3 +- src/actions/recipePreview.ts | 3 +- src/actions/requests.ts | 4 +- src/actions/service.ts | 3 +- src/actions/settings.ts | 3 +- src/actions/ui.ts | 3 +- src/actions/user.ts | 3 +- src/api/index.ts | 12 +- src/app.js | 4 +- src/config.ts | 8 +- src/electron-util.ts | 2 +- src/index.ts | 2 +- src/models/User.ts | 4 +- src/stores.types.ts | 44 ++-- src/stores/AppStore.js | 573 ----------------------------------------- src/stores/AppStore.ts | 587 +++++++++++++++++++++++++++++++++++++++++++ src/stores/index.ts | 13 +- src/stores/lib/Reaction.ts | 14 +- src/stores/lib/Store.js | 13 +- src/stores/lib/TypedStore.ts | 46 ++++ tsconfig.json | 22 +- 28 files changed, 781 insertions(+), 636 deletions(-) delete mode 100644 src/stores/AppStore.js create mode 100644 src/stores/AppStore.ts create mode 100644 src/stores/lib/TypedStore.ts diff --git a/.eslintrc.js b/.eslintrc.js index 6caa782f5..bbf0e6022 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,5 @@ +/** @type {import('eslint').Linter.Config} */ + module.exports = { root: true, parser: '@babel/eslint-parser', diff --git a/babel.config.json b/babel.config.json index 1be7f69d0..71ed8b7bf 100644 --- a/babel.config.json +++ b/babel.config.json @@ -4,12 +4,12 @@ "@babel/preset-env", { "targets": { - "electron": 18 + "electron": 19 } } ], ["@babel/preset-react", { "runtime": "automatic" }], - "@babel/preset-typescript", + "@babel/preset-typescript" ], "plugins": [ [ diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 1e6b1bbbd..f7c016c2b 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -70,16 +70,12 @@ const paths = { javascripts: { src: 'src/**/*.js', dest: 'build/', - watch: [ - 'src/**/*.js', - ], + watch: 'src/**/*.js', }, typescripts: { src: ['src/**/*.ts', 'src/**/*.tsx'], dest: 'build/', - watch: [ - 'src/**/*.ts', - ], + watch: ['src/**/*.ts', 'src/**/*.tsx'], }, }; diff --git a/jest.config.js b/jest.config.js index c7fd4a604..264014997 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ /* * For a detailed explanation regarding each configuration property and type check, visit: * https://jestjs.io/docs/configuration diff --git a/src/I18n.tsx b/src/I18n.tsx index 0e63d1086..bf4b08cd1 100644 --- a/src/I18n.tsx +++ b/src/I18n.tsx @@ -10,7 +10,7 @@ const translations = generatedTranslations(); type Props = { stores: { - app: typeof AppStore; + app: AppStore; user: typeof UserStore; }; children: ReactNode; @@ -21,14 +21,16 @@ class I18N extends Component { window['ferdium'].menu.rebuild(); } - render() { + render(): ReactNode { const { stores, children } = this.props; const { locale } = stores.app; return ( { - window['ferdium'].intl = intlProvider ? intlProvider.state.intl : null; + window['ferdium'].intl = intlProvider + ? intlProvider.state.intl + : null; }} > {children} diff --git a/src/actions/app.ts b/src/actions/app.ts index e6f7f22ba..a798a8b0b 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { setBadge: { unreadDirectMessageCount: PropTypes.number.isRequired, unreadIndirectMessageCount: PropTypes.number, diff --git a/src/actions/lib/actions.ts b/src/actions/lib/actions.ts index 412a0d895..378cef574 100644 --- a/src/actions/lib/actions.ts +++ b/src/actions/lib/actions.ts @@ -1,4 +1,27 @@ -export const createActionsFromDefinitions = (actionDefinitions, validate) => { +import PropTypes from 'prop-types'; + +export interface ActionDefinitions { + [key: string]: { + [key: string]: PropTypes.InferType; + }; +} + +export interface Actions { + [key: string]: { + [key: string]: { + (...args: any[]): void; + listeners: Array; + notify: (params: any) => void; + listen: (listener: (params: any) => void) => void; + off: (listener: (params: any) => void) => void; + }; + }; +} + +export const createActionsFromDefinitions = ( + actionDefinitions: ActionDefinitions, + validate: any, +) => { const actions = {}; // eslint-disable-next-line unicorn/no-array-for-each Object.keys(actionDefinitions).forEach(actionName => { diff --git a/src/actions/recipe.ts b/src/actions/recipe.ts index 29b0a151f..0dd92737f 100644 --- a/src/actions/recipe.ts +++ b/src/actions/recipe.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { install: { recipeId: PropTypes.string.isRequired, update: PropTypes.bool, diff --git a/src/actions/recipePreview.ts b/src/actions/recipePreview.ts index 36de3d844..053b363e9 100644 --- a/src/actions/recipePreview.ts +++ b/src/actions/recipePreview.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { search: { needle: PropTypes.string.isRequired, }, diff --git a/src/actions/requests.ts b/src/actions/requests.ts index 89296e7ec..0b4c905ee 100644 --- a/src/actions/requests.ts +++ b/src/actions/requests.ts @@ -1,3 +1,5 @@ -export default { +import { ActionDefinitions } from './lib/actions'; + +export default { retryRequiredRequests: {}, }; diff --git a/src/actions/service.ts b/src/actions/service.ts index aa02c860a..4b43fc2ca 100644 --- a/src/actions/service.ts +++ b/src/actions/service.ts @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; import ServiceModel from '../models/Service'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { setActive: { serviceId: PropTypes.string.isRequired, keepActiveRoute: PropTypes.bool, diff --git a/src/actions/settings.ts b/src/actions/settings.ts index fd29b798b..4796f6a02 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { update: { type: PropTypes.string.isRequired, data: PropTypes.object.isRequired, diff --git a/src/actions/ui.ts b/src/actions/ui.ts index b913b430b..7d2dbccfa 100644 --- a/src/actions/ui.ts +++ b/src/actions/ui.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { openSettings: { path: PropTypes.string, }, diff --git a/src/actions/user.ts b/src/actions/user.ts index 15a9216bd..c0ede619e 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; +import { ActionDefinitions } from './lib/actions'; -export default { +export default { login: { email: PropTypes.string.isRequired, password: PropTypes.string.isRequired, diff --git a/src/api/index.ts b/src/api/index.ts index 59fad194a..5ca6ba132 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,7 +6,17 @@ import UserApi from './UserApi'; import LocalApi from './LocalApi'; import FeaturesApi from './FeaturesApi'; -export default (server: any, local: any) => ({ +export interface ApiInterface { + app: AppApi; + services: ServicesApi; + recipePreviews: RecipePreviewsApi; + recipes: RecipesApi; + features: FeaturesApi; + user: UserApi; + local: LocalApi; +} + +export default (server: any, local: any): ApiInterface => ({ app: new AppApi(server), services: new ServicesApi(server, local), recipePreviews: new RecipePreviewsApi(server), diff --git a/src/app.js b/src/app.js index 54fba0c71..c92d044e6 100644 --- a/src/app.js +++ b/src/app.js @@ -53,8 +53,8 @@ window.addEventListener('load', () => { // TODO: send this request to the recipe.js window.addEventListener('mouseup', e => { if (e.button === 3 || e.button === 4) { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); } }); diff --git a/src/config.ts b/src/config.ts index 62b4f4e68..d29480dc6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,7 +86,7 @@ export const SEARCH_ENGINE_NAMES = { export const SEARCH_ENGINE_URLS = { [SEARCH_ENGINE_STARTPAGE]: ({ searchTerm }) => - `https://www.startpage.com/sp/search?query=${searchTerm}`, + `https://www.startpage.com/sp/search?query=${searchTerm}`, [SEARCH_ENGINE_GOOGLE]: ({ searchTerm }) => `https://www.google.com/search?q=${searchTerm}`, [SEARCH_ENGINE_DDG]: ({ searchTerm }) => @@ -105,7 +105,7 @@ const TODO_RTM_URL = 'https://www.rememberthemilk.com/'; const TODO_ANYDO_URL = 'https://desktop.any.do/'; const TODO_GOOGLETASKS_URL = 'https://tasks.google.com/embed/?origin=https%3A%2F%2Fcalendar.google.com&fullWidth=1'; -const TODO_GOOGLEKEEP_URL = 'https://keep.google.com/' +const TODO_GOOGLEKEEP_URL = 'https://keep.google.com/'; export const TODO_SERVICE_RECIPE_IDS = { [TODO_TODOIST_URL]: 'todoist', @@ -152,8 +152,8 @@ export const SIDEBAR_SERVICES_LOCATION_BOTTOMRIGHT = 2; export const SIDEBAR_SERVICES_LOCATION = { [SIDEBAR_SERVICES_LOCATION_TOPLEFT]: 'Top/Left', [SIDEBAR_SERVICES_LOCATION_CENTER]: 'Center', - [SIDEBAR_SERVICES_LOCATION_BOTTOMRIGHT]: 'Bottom/Right' -} + [SIDEBAR_SERVICES_LOCATION_BOTTOMRIGHT]: 'Bottom/Right', +}; export const ICON_SIZES = { 0: 'Very small icons', diff --git a/src/electron-util.ts b/src/electron-util.ts index eede55786..4576de9a6 100644 --- a/src/electron-util.ts +++ b/src/electron-util.ts @@ -15,7 +15,7 @@ export const initializeRemote = () => { export const enableWebContents = (webContents: electron.WebContents) => { enable(webContents); -} +}; export const remote = new Proxy( {}, diff --git a/src/index.ts b/src/index.ts index df044ab7e..fa957bf10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ function onDidLoad(fn: { (window: BrowserWindow): void; (window: BrowserWindow): void; (arg0: BrowserWindow): void; -}) { +}): void { if (onDidLoadFns) { onDidLoadFns.push(fn); } else if (mainWindow) { diff --git a/src/models/User.ts b/src/models/User.ts index 8c8f3e490..14481fbb6 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -8,7 +8,7 @@ interface IUser { organization: string | null; accountType: string | null; beta: boolean; - locale: boolean; + locale: string; isSubscriptionOwner: boolean; team: object; } @@ -37,7 +37,7 @@ export default class User { @observable beta = false; - @observable locale = false; + @observable locale: string | null = null; @observable team = {}; diff --git a/src/stores.types.ts b/src/stores.types.ts index d09916653..14ae32133 100644 --- a/src/stores.types.ts +++ b/src/stores.types.ts @@ -1,3 +1,9 @@ +import Workspace from './features/workspaces/models/Workspace'; +import Recipe from './models/Recipe'; +import Service from './models/Service'; +import User from './models/User'; +import { CachedRequest } from './stores/lib/CachedRequest'; + export interface FerdiumStores { app: AppStore; communityRecipes: CommunityRecipesStore; @@ -15,7 +21,7 @@ export interface FerdiumStores { workspaces: WorkspacesStore; } -interface Stores { +export interface Stores { app: AppStore; communityRecipes: CommunityRecipesStore; features: FeaturesStore; @@ -62,9 +68,11 @@ interface AppStore { api: Api; authRequestFailed: () => void; autoLaunchOnStart: () => void; + automaticUpdates: boolean; clearAppCacheRequest: () => void; dictionaries: []; fetchDataInterval: 4; + get(key: string): any; getAppCacheSizeRequest: () => void; healthCheckRequest: () => void; isClearingAllCache: () => void; @@ -74,6 +82,8 @@ interface AppStore { isSystemDarkModeEnabled: () => void; isSystemMuteOverridden: () => void; locale: () => void; + reloadAfterResume: boolean; + reloadAfterResumeTime: number; stores: Stores; timeOfflineStart: () => void; timeSuspensionStart: () => void; @@ -95,8 +105,8 @@ interface AppStore { interface CommunityRecipesStore { actions: Actions; stores: Stores; - _actions: []; - _reactions: []; + _actions: any[]; + _reactions: any[]; communityRecipes: () => void; } @@ -105,7 +115,7 @@ interface FeaturesStore { api: Api; defaultFeaturesRequest: () => void; features: () => void; - featuresRequest: () => void; + featuresRequest: CachedRequest; stores: Stores; _reactions: any[]; _status: () => void; @@ -136,8 +146,8 @@ interface RecipePreviewsStore { _reactions: []; _status: () => void; actionStatus: () => void; - all: () => void; - dev: () => void; + all: Recipe[]; + dev: Recipe[]; searchResults: () => void; } @@ -152,7 +162,7 @@ interface RecipeStore { _status: () => void; actionStatus: () => void; active: () => void; - all: () => void; + all: Recipe[]; recipeIdForServices: () => void; } @@ -179,7 +189,7 @@ interface RouterStore { goForward: () => void; history: () => void; location: () => void; - push: () => void; + push(path: string): void; replace: () => void; } @@ -200,7 +210,7 @@ export interface ServicesStore { actionStatus: () => void; active: () => void; activeSettings: () => void; - all: () => void; + all: Service[]; allDisplayed: () => void; allDisplayedUnordered: () => void; enabled: () => void; @@ -209,6 +219,11 @@ export interface ServicesStore { isTodosServiceAdded: () => void; } +// TODO: Create actual type based on the default config in config.ts +interface ISettings { + [key: string]: any; +} + interface SettingsStore { actions: Actions; api: Api; @@ -220,7 +235,7 @@ interface SettingsStore { _reactions: []; _status: () => void; actionStatus: () => void; - all: () => void; + all: ISettings; app: AppStore; migration: () => void; proxy: () => void; @@ -301,7 +316,7 @@ interface UserStore { deleteAccountRequest: () => void; fetchUserInfoInterval: null; getLegacyServicesRequest: () => void; - getUserInfoRequest: () => void; + getUserInfoRequest: CachedRequest; hasCompletedSignup: () => void; id: () => void; inviteRequest: () => void; @@ -320,11 +335,11 @@ interface UserStore { _requireAuthenticatedUser: () => void; _status: () => void; changeServerRoute: () => void; - data: () => void; + data: User; importRoute: () => void; inviteRoute: () => void; - isLoggedIn: () => void; - isTokenExpired: () => void; + isLoggedIn: boolean; + isTokenExpired: () => boolean; legacyServices: () => void; loginRoute: () => void; logoutRoute: () => void; @@ -348,6 +363,7 @@ export interface WorkspacesStore { isWorkspaceDrawerOpen: () => void; nextWorkspace: () => void; stores: Stores; + workspaces: Workspace[]; workspaceBeingEdited: () => void; _actions: any[]; _activateLastUsedWorkspaceReaction: () => void; diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js deleted file mode 100644 index a8e1ce247..000000000 --- a/src/stores/AppStore.js +++ /dev/null @@ -1,573 +0,0 @@ -import { ipcRenderer } from 'electron'; -import { - app, - screen, - powerMonitor, - nativeTheme, - getCurrentWindow, - process as remoteProcess, -} from '@electron/remote'; -import { action, computed, observable } from 'mobx'; -import moment from 'moment'; -import AutoLaunch from 'auto-launch'; -import ms from 'ms'; -import { URL } from 'url'; -import { readJsonSync } from 'fs-extra'; - -import Store from './lib/Store'; -import Request from './lib/Request'; -import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config'; -import { cleanseJSObject } from '../jsUtils'; -import { isMac, isWindows, electronVersion, osRelease } from '../environment'; -import { ferdiumVersion, userDataPath, ferdiumLocale } from '../environment-remote'; -import { generatedTranslations } from '../i18n/translations'; -import { getLocale } from '../helpers/i18n-helpers'; - -import { - getServiceIdsFromPartitions, - removeServicePartitionDirectory, -} from '../helpers/service-helpers'; -import { openExternalUrl } from '../helpers/url-helpers'; -import { sleep } from '../helpers/async-helpers'; - -const debug = require('../preload-safe-debug')('Ferdium:AppStore'); - -const mainWindow = getCurrentWindow(); - -const executablePath = isMac ? remoteProcess.execPath : process.execPath; -const autoLauncher = new AutoLaunch({ - name: 'Ferdium', - path: executablePath, -}); - -const CATALINA_NOTIFICATION_HACK_KEY = - '_temp_askedForCatalinaNotificationPermissions'; - -const locales = generatedTranslations(); - -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 getAppCacheSizeRequest = new Request( - this.api.local, - 'getAppCacheSize', - ); - - @observable clearAppCacheRequest = new Request(this.api.local, 'clearCache'); - - @observable autoLaunchOnStart = true; - - @observable isOnline = navigator.onLine; - - @observable authRequestFailed = false; - - @observable timeSuspensionStart = moment(); - - @observable timeOfflineStart; - - @observable updateStatus = ''; - - @observable locale = ferdiumLocale; - - @observable isSystemMuteOverridden = false; - - @observable isSystemDarkModeEnabled = false; - - @observable isClearingAllCache = false; - - @observable isFullScreen = mainWindow.isFullScreen(); - - @observable isFocused = true; - - dictionaries = []; - - fetchDataInterval = null; - - constructor(...args) { - 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.actions.app.muteApp.listen(this._muteApp.bind(this)); - this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this)); - this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this)); - - this.registerReactions([ - this._offlineCheck.bind(this), - this._setLocale.bind(this), - this._muteAppHandler.bind(this), - this._handleFullScreen.bind(this), - this._handleLogout.bind(this), - ]); - } - - async 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; - }); - - mainWindow.on('enter-full-screen', () => { - this.isFullScreen = true; - }); - mainWindow.on('leave-full-screen', () => { - this.isFullScreen = false; - }); - - this.isOnline = navigator.onLine; - - // Check if Ferdium should launch on start - // Needs to be delayed a bit - this._autoStart(); - - // Check if system is muted - // There are no events to subscribe so we need to poll everey 5s - this._systemDND(); - setInterval(() => this._systemDND(), ms('5s')); - - this.fetchDataInterval = setInterval(() => { - this.stores.user.getUserInfoRequest.invalidate({ - immediately: true, - }); - this.stores.features.featuresRequest.invalidate({ - immediately: true, - }); - }, ms('60m')); - - // 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(), ms('30s')); - ipcRenderer.on('autoUpdate', (event, data) => { - if (this.updateStatus !== this.updateStatusTypes.FAILED) { - if (data.available) { - this.updateStatus = this.updateStatusTypes.AVAILABLE; - if (isMac && this.stores.settings.app.automaticUpdates) { - app.dock.bounce(); - } - } - - if (data.available !== undefined && !data.available) { - this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; - } - - if (data.downloaded) { - this.updateStatus = this.updateStatusTypes.DOWNLOADED; - if (isMac && this.stores.settings.app.automaticUpdates) { - app.dock.bounce(); - } - } - - if (data.error) { - if (data.error.message && data.error.message.startsWith('404')) { - this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; - console.warn('Updater warning: there seems to be unpublished pre-release(s) available on GitHub', data.error); - } else { - console.error('Updater error:', data.error); - this.updateStatus = this.updateStatusTypes.FAILED; - } - } - } - }); - - // Handle deep linking (ferdium://) - ipcRenderer.on('navigateFromDeepLink', (event, data) => { - debug('Navigate from deep link', data); - let { url } = data; - if (!url) return; - - url = url.replace(/\/$/, ''); - - this.stores.router.push(url); - }); - - ipcRenderer.on('muteApp', () => { - this._toggleMuteApp(); - }); - - this.locale = this._getDefaultLocale(); - - setTimeout(() => { - this._healthCheck(); - }, 1000); - - this.isSystemDarkModeEnabled = nativeTheme.shouldUseDarkColors; - - ipcRenderer.on('isWindowFocused', (event, isFocused) => { - debug('Setting is focused to', isFocused); - this.isFocused = isFocused; - }); - - powerMonitor.on('suspend', () => { - debug('System suspended starting timer'); - - this.timeSuspensionStart = moment(); - }); - - powerMonitor.on('resume', () => { - debug('System resumed, last suspended on', this.timeSuspensionStart); - this.actions.service.resetLastPollTimer(); - - const idleTime = this.stores.settings.app.reloadAfterResumeTime; - - if ( - this.timeSuspensionStart.add(idleTime, 'm').isBefore(moment()) && - this.stores.settings.app.reloadAfterResume - ) { - debug('Reloading services, user info and features'); - - setInterval(() => { - debug('Reload app interval is starting'); - if (this.isOnline) { - window.location.reload(); - } - }, ms('2s')); - } - }); - - // macOS catalina notifications hack - // notifications got stuck after upgrade but forcing a notification - // via `new Notification` triggered the permission request - if (isMac && !localStorage.getItem(CATALINA_NOTIFICATION_HACK_KEY)) { - debug('Triggering macOS Catalina notification permission trigger'); - // eslint-disable-next-line no-new - new window.Notification('Welcome to Ferdium 5', { - body: 'Have a wonderful day & happy messaging.', - }); - - localStorage.setItem(CATALINA_NOTIFICATION_HACK_KEY, 'true'); - } - } - - @computed get cacheSize() { - return this.getAppCacheSizeRequest.execute().result; - } - - @computed get debugInfo() { - const settings = cleanseJSObject(this.stores.settings.app); - settings.lockedPassword = '******'; - - return { - host: { - platform: process.platform, - release: osRelease, - screens: screen.getAllDisplays(), - }, - ferdium: { - version: ferdiumVersion, - electron: electronVersion, - installedRecipes: this.stores.recipes.all.map(recipe => ({ - id: recipe.id, - version: recipe.version, - })), - devRecipes: this.stores.recipePreviews.dev.map(recipe => ({ - id: recipe.id, - version: recipe.version, - })), - services: this.stores.services.all.map(service => ({ - id: service.id, - recipe: service.recipe.id, - isAttached: service.isAttached, - isActive: service.isActive, - isEnabled: service.isEnabled, - isHibernating: service.isHibernating, - hasCrashed: service.hasCrashed, - isDarkModeEnabled: service.isDarkModeEnabled, - isProgressbarEnabled: service.isProgressbarEnabled, - })), - messages: this.stores.globalError.messages, - workspaces: this.stores.workspaces.workspaces.map(workspace => ({ - id: workspace.id, - services: workspace.services, - })), - windowSettings: readJsonSync(userDataPath('window-state.json')), - settings, - features: this.stores.features.features, - user: this.stores.user.data.id, - }, - }; - } - - // Actions - @action _notify({ title, options, notificationId, serviceId = null }) { - if (this.stores.settings.all.app.isAppMuted) return; - - // TODO: is there a simple way to use blobs for notifications without storing them on disk? - if (options.icon && options.icon.startsWith('blob:')) { - delete options.icon; - } - - const notification = new window.Notification(title, options); - - debug('New notification', title, options); - - notification.addEventListener('click', () => { - if (serviceId) { - this.actions.service.sendIPCMessage({ - channel: `notification-onclick:${notificationId}`, - args: {}, - serviceId, - }); - - this.actions.service.setActive({ - serviceId, - }); - - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); - - debug('Notification click handler'); - } - }); - } - - @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) { - let indicator = unreadDirectMessageCount; - - if (indicator === 0 && unreadIndirectMessageCount !== 0) { - indicator = '•'; - } else if ( - unreadDirectMessageCount === 0 && - unreadIndirectMessageCount === 0 - ) { - indicator = 0; - } else { - indicator = Number.parseInt(indicator, 10); - } - - ipcRenderer.send('updateAppIndicator', { - indicator, - }); - } - - @action _launchOnStartup({ enable }) { - this.autoLaunchOnStart = enable; - - try { - if (enable) { - debug('enabling launch on startup', executablePath); - autoLauncher.enable(); - } else { - debug('disabling launch on startup'); - autoLauncher.disable(); - } - } catch (error) { - console.warn(error); - } - } - - // Ideally(?) this should be merged with the 'shell-helpers' functionality - @action _openExternalUrl({ url }) { - openExternalUrl(new URL(url)); - } - - @action _checkForUpdates() { - if (this.isOnline && this.stores.settings.app.automaticUpdates && (isMac || isWindows || process.env.APPIMAGE)) { - debug('_checkForUpdates: sending event to autoUpdate:check'); - this.updateStatus = this.updateStatusTypes.CHECKING; - ipcRenderer.send('autoUpdate', { - action: 'check', - }); - } - - if (this.isOnline && this.stores.settings.app.automaticUpdates) { - this.actions.recipe.update(); - } - } - - @action _installUpdate() { - debug('_installUpdate: sending event to autoUpdate:install'); - ipcRenderer.send('autoUpdate', { - action: 'install', - }); - } - - @action _resetUpdateStatus() { - this.updateStatus = ''; - } - - @action _healthCheck() { - this.healthCheckRequest.execute(); - } - - @action _muteApp({ isMuted, overrideSystemMute = true }) { - this.isSystemMuteOverridden = overrideSystemMute; - this.actions.settings.update({ - type: 'app', - data: { - isAppMuted: isMuted, - }, - }); - } - - @action _toggleMuteApp() { - this._muteApp({ - isMuted: !this.stores.settings.all.app.isAppMuted, - }); - } - - @action async _clearAllCache() { - this.isClearingAllCache = true; - const clearAppCache = this.clearAppCacheRequest.execute(); - const allServiceIds = await getServiceIdsFromPartitions(); - const allOrphanedServiceIds = allServiceIds.filter( - id => - !this.stores.services.all.some( - s => id.replace('service-', '') === s.id, - ), - ); - - try { - await Promise.all( - allOrphanedServiceIds.map(id => removeServicePartitionDirectory(id)), - ); - } catch (error) { - console.log('Error while deleting service partition directory -', error); - } - await Promise.all( - this.stores.services.all.map(s => - this.actions.service.clearCache({ - serviceId: s.id, - }), - ), - ); - - await clearAppCache._promise; - - await sleep(ms('1s')); - - this.getAppCacheSizeRequest.execute(); - - this.isClearingAllCache = false; - } - - // Reactions - _offlineCheck() { - if (!this.isOnline) { - this.timeOfflineStart = moment(); - } else { - const deltaTime = moment().diff(this.timeOfflineStart); - - if (deltaTime > ms('30m')) { - this.actions.service.reloadAll(); - } - } - } - - _setLocale() { - if (this.stores.user.isLoggedIn && this.stores.user.data.locale) { - this.locale = this.stores.user.data.locale; - } else if (!this.locale) { - this.locale = this._getDefaultLocale(); - } - - moment.locale(this.locale); - debug(`Set locale to "${this.locale}"`); - } - - _getDefaultLocale() { - return getLocale({ - locale: ferdiumLocale, - locales, - fallbackLocale: DEFAULT_APP_SETTINGS.fallbackLocale, - }); - } - - _muteAppHandler() { - const { showMessageBadgesEvenWhenMuted } = this.stores.ui; - - if (!showMessageBadgesEvenWhenMuted) { - this.actions.app.setBadge({ - unreadDirectMessageCount: 0, - unreadIndirectMessageCount: 0, - }); - } - } - - _handleFullScreen() { - const body = document.querySelector('body'); - - if (body) { - if (this.isFullScreen) { - body.classList.add('isFullScreen'); - } else { - body.classList.remove('isFullScreen'); - } - } - } - - _handleLogout() { - if (!this.stores.user.isLoggedIn) { - clearInterval(this.fetchDataInterval); - } - } - - // Helpers - _appStartsCounter() { - this.actions.settings.update({ - type: 'stats', - data: { - appStarts: (this.stores.settings.all.stats.appStarts || 0) + 1, - }, - }); - } - - async _autoStart() { - this.autoLaunchOnStart = await this._checkAutoStart(); - - if (this.stores.settings.all.stats.appStarts === 1) { - debug('Set app to launch on start'); - this.actions.app.launchOnStartup({ - enable: true, - }); - } - } - - async _checkAutoStart() { - return autoLauncher.isEnabled() || false; - } - - async _systemDND() { - debug('Checking if Do Not Disturb Mode is on'); - const dnd = await ipcRenderer.invoke('get-dnd'); - debug('Do not disturb mode is', dnd); - if ( - dnd !== this.stores.settings.all.app.isAppMuted && - !this.isSystemMuteOverridden - ) { - this.actions.app.muteApp({ - isMuted: dnd, - overrideSystemMute: false, - }); - } - } -} diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts new file mode 100644 index 000000000..5659460c6 --- /dev/null +++ b/src/stores/AppStore.ts @@ -0,0 +1,587 @@ +import { ipcRenderer } from 'electron'; +import { + app, + screen, + powerMonitor, + nativeTheme, + getCurrentWindow, + process as remoteProcess, +} from '@electron/remote'; +import { action, computed, observable } from 'mobx'; +import moment from 'moment'; +import AutoLaunch from 'auto-launch'; +import ms from 'ms'; +import { URL } from 'url'; +import { readJsonSync } from 'fs-extra'; + +import { Stores } from 'src/stores.types'; +import { ApiInterface } from 'src/api'; +import { Actions } from 'src/actions/lib/actions'; +import TypedStore from './lib/TypedStore'; +import Request from './lib/Request'; +import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config'; +import { cleanseJSObject } from '../jsUtils'; +import { isMac, isWindows, electronVersion, osRelease } from '../environment'; +import { + ferdiumVersion, + userDataPath, + ferdiumLocale, +} from '../environment-remote'; +import { generatedTranslations } from '../i18n/translations'; +import { getLocale } from '../helpers/i18n-helpers'; + +import { + getServiceIdsFromPartitions, + removeServicePartitionDirectory, +} from '../helpers/service-helpers'; +import { openExternalUrl } from '../helpers/url-helpers'; +import { sleep } from '../helpers/async-helpers'; + +const debug = require('../preload-safe-debug')('Ferdium:AppStore'); + +const mainWindow = getCurrentWindow(); + +const executablePath = isMac ? remoteProcess.execPath : process.execPath; +const autoLauncher = new AutoLaunch({ + name: 'Ferdium', + path: executablePath, +}); + +const CATALINA_NOTIFICATION_HACK_KEY = + '_temp_askedForCatalinaNotificationPermissions'; + +const locales = generatedTranslations(); + +export default class AppStore extends TypedStore { + updateStatusTypes = { + CHECKING: 'CHECKING', + AVAILABLE: 'AVAILABLE', + NOT_AVAILABLE: 'NOT_AVAILABLE', + DOWNLOADED: 'DOWNLOADED', + FAILED: 'FAILED', + }; + + @observable healthCheckRequest = new Request(this.api.app, 'health'); + + @observable getAppCacheSizeRequest = new Request( + this.api.local, + 'getAppCacheSize', + ); + + @observable clearAppCacheRequest = new Request(this.api.local, 'clearCache'); + + @observable autoLaunchOnStart = true; + + @observable isOnline = navigator.onLine; + + @observable authRequestFailed = false; + + @observable timeSuspensionStart = moment(); + + @observable timeOfflineStart; + + @observable updateStatus = ''; + + @observable locale = ferdiumLocale; + + @observable isSystemMuteOverridden = false; + + @observable isSystemDarkModeEnabled = false; + + @observable isClearingAllCache = false; + + @observable isFullScreen = mainWindow.isFullScreen(); + + @observable isFocused = true; + + dictionaries = []; + + fetchDataInterval: null | NodeJS.Timer = null; + + constructor(stores: Stores, api: ApiInterface, actions: Actions) { + super(stores, api, actions); + + // 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.actions.app.muteApp.listen(this._muteApp.bind(this)); + this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this)); + this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this)); + + this.registerReactions([ + this._offlineCheck.bind(this), + this._setLocale.bind(this), + this._muteAppHandler.bind(this), + this._handleFullScreen.bind(this), + this._handleLogout.bind(this), + ]); + } + + async setup(): Promise { + 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; + }); + + mainWindow.on('enter-full-screen', () => { + this.isFullScreen = true; + }); + mainWindow.on('leave-full-screen', () => { + this.isFullScreen = false; + }); + + this.isOnline = navigator.onLine; + + // Check if Ferdium should launch on start + // Needs to be delayed a bit + this._autoStart(); + + // Check if system is muted + // There are no events to subscribe so we need to poll everey 5s + this._systemDND(); + setInterval(() => this._systemDND(), ms('5s')); + + this.fetchDataInterval = setInterval(() => { + this.stores.user.getUserInfoRequest.invalidate({ + immediately: true, + }); + this.stores.features.featuresRequest.invalidate({ + immediately: true, + }); + }, ms('60m')); + + // 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(), ms('30s')); + ipcRenderer.on('autoUpdate', (_, data) => { + if (this.updateStatus !== this.updateStatusTypes.FAILED) { + if (data.available) { + this.updateStatus = this.updateStatusTypes.AVAILABLE; + if (isMac && this.stores.settings.app.automaticUpdates) { + app.dock.bounce(); + } + } + + if (data.available !== undefined && !data.available) { + this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; + } + + if (data.downloaded) { + this.updateStatus = this.updateStatusTypes.DOWNLOADED; + if (isMac && this.stores.settings.app.automaticUpdates) { + app.dock.bounce(); + } + } + + if (data.error) { + if (data.error.message && data.error.message.startsWith('404')) { + this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; + console.warn( + 'Updater warning: there seems to be unpublished pre-release(s) available on GitHub', + data.error, + ); + } else { + console.error('Updater error:', data.error); + this.updateStatus = this.updateStatusTypes.FAILED; + } + } + } + }); + + // Handle deep linking (ferdium://) + ipcRenderer.on('navigateFromDeepLink', (_, data) => { + debug('Navigate from deep link', data); + let { url } = data; + if (!url) return; + + url = url.replace(/\/$/, ''); + + this.stores.router.push(url); + }); + + ipcRenderer.on('muteApp', () => { + this._toggleMuteApp(); + }); + + this.locale = this._getDefaultLocale(); + + setTimeout(() => { + this._healthCheck(); + }, 1000); + + this.isSystemDarkModeEnabled = nativeTheme.shouldUseDarkColors; + + ipcRenderer.on('isWindowFocused', (_, isFocused) => { + debug('Setting is focused to', isFocused); + this.isFocused = isFocused; + }); + + powerMonitor.on('suspend', () => { + debug('System suspended starting timer'); + + this.timeSuspensionStart = moment(); + }); + + powerMonitor.on('resume', () => { + debug('System resumed, last suspended on', this.timeSuspensionStart); + this.actions.service.resetLastPollTimer(); + + const idleTime = this.stores.settings.app.reloadAfterResumeTime; + + if ( + this.timeSuspensionStart.add(idleTime, 'm').isBefore(moment()) && + this.stores.settings.app.reloadAfterResume + ) { + debug('Reloading services, user info and features'); + + setInterval(() => { + debug('Reload app interval is starting'); + if (this.isOnline) { + window.location.reload(); + } + }, ms('2s')); + } + }); + + // macOS catalina notifications hack + // notifications got stuck after upgrade but forcing a notification + // via `new Notification` triggered the permission request + if (isMac && !localStorage.getItem(CATALINA_NOTIFICATION_HACK_KEY)) { + debug('Triggering macOS Catalina notification permission trigger'); + // eslint-disable-next-line no-new + new window.Notification('Welcome to Ferdium 5', { + body: 'Have a wonderful day & happy messaging.', + }); + + localStorage.setItem(CATALINA_NOTIFICATION_HACK_KEY, 'true'); + } + } + + @computed get cacheSize() { + return this.getAppCacheSizeRequest.execute().result; + } + + @computed get debugInfo() { + const settings = cleanseJSObject(this.stores.settings.app); + settings.lockedPassword = '******'; + + return { + host: { + platform: process.platform, + release: osRelease, + screens: screen.getAllDisplays(), + }, + ferdium: { + version: ferdiumVersion, + electron: electronVersion, + installedRecipes: this.stores.recipes.all.map(recipe => ({ + id: recipe.id, + version: recipe.version, + })), + devRecipes: this.stores.recipePreviews.dev.map(recipe => ({ + id: recipe.id, + version: recipe.version, + })), + services: this.stores.services.all.map(service => ({ + id: service.id, + recipe: service.recipe.id, + isAttached: service.isAttached, + isActive: service.isActive, + isEnabled: service.isEnabled, + isHibernating: service.isHibernating, + hasCrashed: service.hasCrashed, + isDarkModeEnabled: service.isDarkModeEnabled, + isProgressbarEnabled: service.isProgressbarEnabled, + })), + messages: this.stores.globalError.messages, + workspaces: this.stores.workspaces.workspaces.map(workspace => ({ + id: workspace.id, + services: workspace.services, + })), + windowSettings: readJsonSync(userDataPath('window-state.json')), + settings, + features: this.stores.features.features, + user: this.stores.user.data.id, + }, + }; + } + + // Actions + @action _notify({ title, options, notificationId, serviceId = null }) { + if (this.stores.settings.all.app.isAppMuted) return; + + // TODO: is there a simple way to use blobs for notifications without storing them on disk? + if (options.icon && options.icon.startsWith('blob:')) { + delete options.icon; + } + + const notification = new window.Notification(title, options); + + debug('New notification', title, options); + + notification.addEventListener('click', () => { + if (serviceId) { + this.actions.service.sendIPCMessage({ + channel: `notification-onclick:${notificationId}`, + args: {}, + serviceId, + }); + + this.actions.service.setActive({ + serviceId, + }); + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + + debug('Notification click handler'); + } + }); + } + + @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) { + let indicator = unreadDirectMessageCount; + + if (indicator === 0 && unreadIndirectMessageCount !== 0) { + indicator = '•'; + } else if ( + unreadDirectMessageCount === 0 && + unreadIndirectMessageCount === 0 + ) { + indicator = 0; + } else { + indicator = Number.parseInt(indicator, 10); + } + + ipcRenderer.send('updateAppIndicator', { + indicator, + }); + } + + @action _launchOnStartup({ enable }) { + this.autoLaunchOnStart = enable; + + try { + if (enable) { + debug('enabling launch on startup', executablePath); + autoLauncher.enable(); + } else { + debug('disabling launch on startup'); + autoLauncher.disable(); + } + } catch (error) { + console.warn(error); + } + } + + // Ideally(?) this should be merged with the 'shell-helpers' functionality + @action _openExternalUrl({ url }) { + openExternalUrl(new URL(url)); + } + + @action _checkForUpdates() { + if ( + this.isOnline && + this.stores.settings.app.automaticUpdates && + (isMac || isWindows || process.env.APPIMAGE) + ) { + debug('_checkForUpdates: sending event to autoUpdate:check'); + this.updateStatus = this.updateStatusTypes.CHECKING; + ipcRenderer.send('autoUpdate', { + action: 'check', + }); + } + + if (this.isOnline && this.stores.settings.app.automaticUpdates) { + this.actions.recipe.update(); + } + } + + @action _installUpdate() { + debug('_installUpdate: sending event to autoUpdate:install'); + ipcRenderer.send('autoUpdate', { + action: 'install', + }); + } + + @action _resetUpdateStatus() { + this.updateStatus = ''; + } + + @action _healthCheck() { + this.healthCheckRequest.execute(); + } + + @action _muteApp({ isMuted, overrideSystemMute = true }) { + this.isSystemMuteOverridden = overrideSystemMute; + this.actions.settings.update({ + type: 'app', + data: { + isAppMuted: isMuted, + }, + }); + } + + @action _toggleMuteApp() { + this._muteApp({ + isMuted: !this.stores.settings.all.app.isAppMuted, + }); + } + + @action async _clearAllCache() { + this.isClearingAllCache = true; + const clearAppCache = this.clearAppCacheRequest.execute(); + const allServiceIds = await getServiceIdsFromPartitions(); + const allOrphanedServiceIds = allServiceIds.filter( + id => + !this.stores.services.all.some( + s => id.replace('service-', '') === s.id, + ), + ); + + try { + await Promise.all( + allOrphanedServiceIds.map(id => removeServicePartitionDirectory(id)), + ); + } catch (error) { + console.log('Error while deleting service partition directory -', error); + } + await Promise.all( + this.stores.services.all.map(s => + this.actions.service.clearCache({ + serviceId: s.id, + }), + ), + ); + + await clearAppCache._promise; + + await sleep(ms('1s')); + + this.getAppCacheSizeRequest.execute(); + + this.isClearingAllCache = false; + } + + // Reactions + _offlineCheck() { + if (!this.isOnline) { + this.timeOfflineStart = moment(); + } else { + const deltaTime = moment().diff(this.timeOfflineStart); + + if (deltaTime > ms('30m')) { + this.actions.service.reloadAll(); + } + } + } + + _setLocale() { + if (this.stores.user.isLoggedIn && this.stores.user.data.locale) { + this.locale = this.stores.user.data.locale; + } else if (!this.locale) { + this.locale = this._getDefaultLocale(); + } + + moment.locale(this.locale); + debug(`Set locale to "${this.locale}"`); + } + + _getDefaultLocale() { + return getLocale({ + locale: ferdiumLocale, + locales, + fallbackLocale: DEFAULT_APP_SETTINGS.fallbackLocale, + }); + } + + _muteAppHandler() { + const { showMessageBadgesEvenWhenMuted } = this.stores.ui; + + if (!showMessageBadgesEvenWhenMuted) { + this.actions.app.setBadge({ + unreadDirectMessageCount: 0, + unreadIndirectMessageCount: 0, + }); + } + } + + _handleFullScreen() { + const body = document.querySelector('body'); + + if (body) { + if (this.isFullScreen) { + body.classList.add('isFullScreen'); + } else { + body.classList.remove('isFullScreen'); + } + } + } + + _handleLogout() { + if (!this.stores.user.isLoggedIn && this.fetchDataInterval !== null) { + clearInterval(this.fetchDataInterval); + } + } + + // Helpers + _appStartsCounter() { + this.actions.settings.update({ + type: 'stats', + data: { + appStarts: (this.stores.settings.all.stats.appStarts || 0) + 1, + }, + }); + } + + async _autoStart() { + this.autoLaunchOnStart = await this._checkAutoStart(); + + if (this.stores.settings.all.stats.appStarts === 1) { + debug('Set app to launch on start'); + this.actions.app.launchOnStartup({ + enable: true, + }); + } + } + + async _checkAutoStart() { + return autoLauncher.isEnabled() || false; + } + + async _systemDND() { + debug('Checking if Do Not Disturb Mode is on'); + const dnd = await ipcRenderer.invoke('get-dnd'); + debug('Do not disturb mode is', dnd); + if ( + dnd !== this.stores.settings.all.app.isAppMuted && + !this.isSystemMuteOverridden + ) { + this.actions.app.muteApp({ + isMuted: dnd, + overrideSystemMute: false, + }); + } + } +} diff --git a/src/stores/index.ts b/src/stores/index.ts index 6ad898d85..a5b1a7452 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,3 +1,7 @@ +import { Stores } from 'src/stores.types'; +import { RouterStore } from 'mobx-react-router'; +import { ApiInterface } from 'src/api'; +import { Actions } from 'src/actions/lib/actions'; import AppStore from './AppStore'; import UserStore from './UserStore'; import FeaturesStore from './FeaturesStore'; @@ -12,8 +16,12 @@ import { workspaceStore } from '../features/workspaces'; import { communityRecipesStore } from '../features/communityRecipes'; import { todosStore } from '../features/todos'; -export default (api, actions, router) => { - const stores = {}; +export default ( + api: ApiInterface, + actions: Actions, + router: RouterStore, +): Stores => { + const stores: Stores | any = {}; Object.assign(stores, { router, app: new AppStore(stores, api, actions), @@ -37,5 +45,6 @@ export default (api, actions, router) => { stores[name].initialize(); } } + return stores; }; diff --git a/src/stores/lib/Reaction.ts b/src/stores/lib/Reaction.ts index 0ca24a6fa..3966c8073 100644 --- a/src/stores/lib/Reaction.ts +++ b/src/stores/lib/Reaction.ts @@ -1,24 +1,24 @@ -import { autorun } from 'mobx'; +import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'; export default class Reaction { - reaction; + public reaction: (r: IReactionPublic) => any; - isRunning = false; + private isRunning: boolean = false; - dispose; + public dispose?: IReactionDisposer; - constructor(reaction) { + constructor(reaction: any) { this.reaction = reaction; } - start() { + start(): void { if (!this.isRunning) { this.dispose = autorun(this.reaction); this.isRunning = true; } } - stop() { + stop(): void { if (this.isRunning) { this.dispose?.(); this.isRunning = false; diff --git a/src/stores/lib/Store.js b/src/stores/lib/Store.js index a867c3a46..739a47729 100644 --- a/src/stores/lib/Store.js +++ b/src/stores/lib/Store.js @@ -2,12 +2,16 @@ import { computed, observable } from 'mobx'; import Reaction from './Reaction'; export default class Store { - stores = {}; + /** @type Stores */ + stores; - api = {}; + /** @type ApiInterface */ + api; - actions = {}; + /** @type Actions */ + actions; + /** @type Reaction[] */ _reactions = []; // status implementation @@ -28,8 +32,9 @@ export default class Store { } registerReactions(reactions) { - for (const reaction of reactions) + for (const reaction of reactions) { this._reactions.push(new Reaction(reaction)); + } } setup() {} diff --git a/src/stores/lib/TypedStore.ts b/src/stores/lib/TypedStore.ts new file mode 100644 index 000000000..5d8bf3bbd --- /dev/null +++ b/src/stores/lib/TypedStore.ts @@ -0,0 +1,46 @@ +import { computed, IReactionPublic, observable } from 'mobx'; +import { Actions } from 'src/actions/lib/actions'; +import { ApiInterface } from 'src/api'; +import { Stores } from 'src/stores.types'; +import Reaction from './Reaction'; + +export default abstract class TypedStore { + _reactions: Reaction[] = []; + + @observable _status: any = null; + + @computed get actionStatus() { + return this._status || []; + } + + set actionStatus(status) { + this._status = status; + } + + constructor( + public stores: Stores, + public api: ApiInterface, + public actions: Actions, + ) {} + + registerReactions(reactions: { (r: IReactionPublic): void }[]): void { + for (const reaction of reactions) { + this._reactions.push(new Reaction(reaction)); + } + } + + public abstract setup(): void; + + initialize(): void { + this.setup(); + for (const reaction of this._reactions) reaction.start(); + } + + teardown(): void { + for (const reaction of this._reactions) reaction.stop(); + } + + resetStatus(): void { + this._status = null; + } +} diff --git a/tsconfig.json b/tsconfig.json index ae0d6083f..c600df5cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,12 +5,21 @@ "baseUrl": ".", "strict": true, "target": "esnext", - "lib": ["esnext", "dom"], + "lib": [ + "esnext", + "dom" + ], "module": "CommonJS", "jsx": "react-jsx", - "typeRoots": ["@types", "node_modules/@types"], + "typeRoots": [ + "@types", + "node_modules/@types" + ], "moduleResolution": "node", - "types": ["node", "jest"], + "types": [ + "node", + "jest" + ], "sourceMap": true, "skipLibCheck": true, // TODO: Need to switch @@ -18,7 +27,7 @@ "pretty": true, "allowSyntheticDefaultImports": true, "experimentalDecorators": true, - "composite": true, + "composite": false, // TODO: Change this config option to false once ms v3 is released and adopted "esModuleInterop": true, "importHelpers": true, @@ -35,5 +44,8 @@ "resolveJsonModule": true, "forceConsistentCasingInFileNames": true }, - "exclude": ["node_modules", "build"] + "include": [ + "src", + "scripts" + ], } -- cgit v1.2.3-54-g00ecf