From 739ef2e8a2dec94c3e10c3d26d797fe759fac7aa Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Fri, 1 Mar 2019 14:25:44 +0100 Subject: finish workspaces mvp --- src/stores/ServicesStore.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) (limited to 'src/stores/ServicesStore.js') diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index c63bef196..a86db8103 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -2,7 +2,7 @@ import { action, reaction, computed, - observable, + observable, runInAction, } from 'mobx'; import { debounce, remove } from 'lodash'; import ms from 'ms'; @@ -12,6 +12,8 @@ import Request from './lib/Request'; import CachedRequest from './lib/CachedRequest'; import { matchRoute } from '../helpers/routing-helpers'; import { gaEvent } from '../lib/analytics'; +import { workspacesState } from '../features/workspaces/state'; +import { filterServicesByActiveWorkspace, getActiveWorkspaceServices } from '../features/workspaces'; const debug = require('debug')('Franz:ServiceStore'); @@ -98,7 +100,6 @@ export default class ServicesStore extends Store { return observable(services.slice().slice().sort((a, b) => a.order - b.order)); } } - return []; } @@ -107,13 +108,16 @@ export default class ServicesStore extends Store { } @computed get allDisplayed() { - return this.stores.settings.all.app.showDisabledServices ? this.all : this.enabled; + const services = this.stores.settings.all.app.showDisabledServices ? this.all : this.enabled; + return filterServicesByActiveWorkspace(services); } // This is just used to avoid unnecessary rerendering of resource-heavy webviews @computed get allDisplayedUnordered() { + const { showDisabledServices } = this.stores.settings.all.app; const services = this.allServicesRequest.execute().result || []; - return this.stores.settings.all.app.showDisabledServices ? services : services.filter(service => service.isEnabled); + const filteredServices = showDisabledServices ? services : services.filter(service => service.isEnabled); + return getActiveWorkspaceServices(filteredServices); } @computed get filtered() { -- cgit v1.2.3-70-g09d2 From 1947cad07e0d9c32ffb874bdea482e7ff037888b Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Fri, 1 Mar 2019 14:27:40 +0100 Subject: fix eslint issues --- src/features/workspaces/index.js | 6 +++--- src/stores/ServicesStore.js | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) (limited to 'src/stores/ServicesStore.js') diff --git a/src/features/workspaces/index.js b/src/features/workspaces/index.js index 8091f49fc..79c9b8ac9 100644 --- a/src/features/workspaces/index.js +++ b/src/features/workspaces/index.js @@ -15,9 +15,9 @@ export const filterServicesByActiveWorkspace = (services) => { return services; }; -export const getActiveWorkspaceServices = (services) => { - return filterServicesByActiveWorkspace(services); -}; +export const getActiveWorkspaceServices = services => ( + filterServicesByActiveWorkspace(services) +); export default function initWorkspaces(stores, actions) { const { features, user } = stores; diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index a86db8103..da4b19c0d 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -2,7 +2,7 @@ import { action, reaction, computed, - observable, runInAction, + observable, } from 'mobx'; import { debounce, remove } from 'lodash'; import ms from 'ms'; @@ -12,7 +12,6 @@ import Request from './lib/Request'; import CachedRequest from './lib/CachedRequest'; import { matchRoute } from '../helpers/routing-helpers'; import { gaEvent } from '../lib/analytics'; -import { workspacesState } from '../features/workspaces/state'; import { filterServicesByActiveWorkspace, getActiveWorkspaceServices } from '../features/workspaces'; const debug = require('debug')('Franz:ServiceStore'); -- cgit v1.2.3-70-g09d2 From cf9d7a30fed4fe50c346e652073464b07277a81e Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Fri, 8 Mar 2019 14:48:46 +0100 Subject: detach service when underlying webview unmounts --- src/actions/service.js | 4 + src/components/services/content/ServiceView.js | 136 ++++++++++++++++++++ src/components/services/content/ServiceWebview.js | 145 ++++------------------ src/components/services/content/Services.js | 7 +- src/containers/layout/AppLayoutContainer.js | 3 + src/stores/ServicesStore.js | 6 + 6 files changed, 179 insertions(+), 122 deletions(-) create mode 100644 src/components/services/content/ServiceView.js (limited to 'src/stores/ServicesStore.js') diff --git a/src/actions/service.js b/src/actions/service.js index 5d483b12a..ceaabc31e 100644 --- a/src/actions/service.js +++ b/src/actions/service.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import ServiceModel from '../models/Service'; export default { setActive: { @@ -36,6 +37,9 @@ export default { serviceId: PropTypes.string.isRequired, webview: PropTypes.object.isRequired, }, + detachService: { + service: PropTypes.instanceOf(ServiceModel).isRequired, + }, focusService: { serviceId: PropTypes.string.isRequired, }, diff --git a/src/components/services/content/ServiceView.js b/src/components/services/content/ServiceView.js new file mode 100644 index 000000000..5afc54f9d --- /dev/null +++ b/src/components/services/content/ServiceView.js @@ -0,0 +1,136 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { autorun } from 'mobx'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; + +import ServiceModel from '../../../models/Service'; +import StatusBarTargetUrl from '../../ui/StatusBarTargetUrl'; +import WebviewLoader from '../../ui/WebviewLoader'; +import WebviewCrashHandler from './WebviewCrashHandler'; +import WebviewErrorHandler from './ErrorHandlers/WebviewErrorHandler'; +import ServiceDisabled from './ServiceDisabled'; +import ServiceWebview from './ServiceWebview'; + +export default @observer class ServiceView extends Component { + static propTypes = { + service: PropTypes.instanceOf(ServiceModel).isRequired, + setWebviewReference: PropTypes.func.isRequired, + detachService: PropTypes.func.isRequired, + reload: PropTypes.func.isRequired, + edit: PropTypes.func.isRequired, + enable: PropTypes.func.isRequired, + isActive: PropTypes.bool, + }; + + static defaultProps = { + isActive: false, + }; + + state = { + forceRepaint: false, + targetUrl: '', + statusBarVisible: false, + }; + + autorunDisposer = null; + + componentDidMount() { + this.autorunDisposer = autorun(() => { + if (this.props.service.isActive) { + this.setState({ forceRepaint: true }); + setTimeout(() => { + this.setState({ forceRepaint: false }); + }, 100); + } + }); + } + + componentWillUnmount() { + this.autorunDisposer(); + } + + updateTargetUrl = (event) => { + let visible = true; + if (event.url === '' || event.url === '#') { + visible = false; + } + this.setState({ + targetUrl: event.url, + statusBarVisible: visible, + }); + }; + + render() { + const { + detachService, + service, + setWebviewReference, + reload, + edit, + enable, + } = this.props; + + const webviewClasses = classnames({ + services__webview: true, + 'services__webview-wrapper': true, + 'is-active': service.isActive, + 'services__webview--force-repaint': this.state.forceRepaint, + }); + + let statusBar = null; + if (this.state.statusBarVisible) { + statusBar = ( + + ); + } + + return ( +
+ {service.isActive && service.isEnabled && ( + + {service.hasCrashed && ( + + )} + {service.isEnabled && service.isLoading && service.isFirstLoad && ( + + )} + {service.isError && ( + + )} + + )} + {!service.isEnabled ? ( + + {service.isActive && ( + + )} + + ) : ( + + )} + {statusBar} +
+ ); + } +} diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js index bb577e4cc..7252c695f 100644 --- a/src/components/services/content/ServiceWebview.js +++ b/src/components/services/content/ServiceWebview.js @@ -1,145 +1,50 @@ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { autorun } from 'mobx'; import { observer } from 'mobx-react'; -import Webview from 'react-electron-web-view'; -import classnames from 'classnames'; +import ElectronWebView from 'react-electron-web-view'; import ServiceModel from '../../../models/Service'; -import StatusBarTargetUrl from '../../ui/StatusBarTargetUrl'; -import WebviewLoader from '../../ui/WebviewLoader'; -import WebviewCrashHandler from './WebviewCrashHandler'; -import WebviewErrorHandler from './ErrorHandlers/WebviewErrorHandler'; -import ServiceDisabled from './ServiceDisabled'; -export default @observer class ServiceWebview extends Component { +@observer +class ServiceWebview extends Component { static propTypes = { service: PropTypes.instanceOf(ServiceModel).isRequired, setWebviewReference: PropTypes.func.isRequired, - reload: PropTypes.func.isRequired, - edit: PropTypes.func.isRequired, - enable: PropTypes.func.isRequired, - isActive: PropTypes.bool, + detachService: PropTypes.func.isRequired, }; - static defaultProps = { - isActive: false, - }; - - state = { - forceRepaint: false, - targetUrl: '', - statusBarVisible: false, - }; - - autorunDisposer = null; - webview = null; - componentDidMount() { - this.autorunDisposer = autorun(() => { - if (this.props.service.isActive) { - this.setState({ forceRepaint: true }); - setTimeout(() => { - this.setState({ forceRepaint: false }); - }, 100); - } - }); - } - componentWillUnmount() { - this.autorunDisposer(); - } - - updateTargetUrl = (event) => { - let visible = true; - if (event.url === '' || event.url === '#') { - visible = false; - } - this.setState({ - targetUrl: event.url, - statusBarVisible: visible, - }); + const { service, detachService } = this.props; + detachService({ service }); } render() { const { service, setWebviewReference, - reload, - edit, - enable, } = this.props; - const webviewClasses = classnames({ - services__webview: true, - 'services__webview-wrapper': true, - 'is-active': service.isActive, - 'services__webview--force-repaint': this.state.forceRepaint, - }); - - let statusBar = null; - if (this.state.statusBarVisible) { - statusBar = ( - - ); - } - return ( -
- {service.isActive && service.isEnabled && ( - - {service.hasCrashed && ( - - )} - {service.isEnabled && service.isLoading && service.isFirstLoad && ( - - )} - {service.isError && ( - - )} - - )} - {!service.isEnabled ? ( - - {service.isActive && ( - - )} - - ) : ( - { this.webview = element; }} - autosize - src={service.url} - preload="./webview/recipe.js" - partition={`persist:service-${service.id}`} - onDidAttach={() => setWebviewReference({ - serviceId: service.id, - webview: this.webview.view, - })} - onUpdateTargetUrl={this.updateTargetUrl} - useragent={service.userAgent} - allowpopups - /> - )} - {statusBar} -
+ { this.webview = webview; }} + autosize + src={service.url} + preload="./webview/recipe.js" + partition={`persist:service-${service.id}`} + onDidAttach={() => { + setWebviewReference({ + serviceId: service.id, + webview: this.webview.view, + }); + }} + onUpdateTargetUrl={this.updateTargetUrl} + useragent={service.userAgent} + allowpopups + /> ); } } + +export default ServiceWebview; diff --git a/src/components/services/content/Services.js b/src/components/services/content/Services.js index 54f16ba12..8f8c38a11 100644 --- a/src/components/services/content/Services.js +++ b/src/components/services/content/Services.js @@ -4,7 +4,7 @@ import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; import { Link } from 'react-router'; import { defineMessages, intlShape } from 'react-intl'; -import Webview from './ServiceWebview'; +import ServiceView from './ServiceView'; import Appear from '../../ui/effects/Appear'; const messages = defineMessages({ @@ -22,6 +22,7 @@ export default @observer class Services extends Component { static propTypes = { services: MobxPropTypes.arrayOrObservableArray, setWebviewReference: PropTypes.func.isRequired, + detachService: PropTypes.func.isRequired, handleIPCMessage: PropTypes.func.isRequired, openWindow: PropTypes.func.isRequired, reload: PropTypes.func.isRequired, @@ -42,6 +43,7 @@ export default @observer class Services extends Component { services, handleIPCMessage, setWebviewReference, + detachService, openWindow, reload, openSettings, @@ -71,11 +73,12 @@ export default @observer class Services extends Component { )} {services.map(service => ( - reload({ serviceId: service.id })} edit={() => openSettings({ path: `services/edit/${service.id}` })} diff --git a/src/containers/layout/AppLayoutContainer.js b/src/containers/layout/AppLayoutContainer.js index 749912c59..5a05ce431 100644 --- a/src/containers/layout/AppLayoutContainer.js +++ b/src/containers/layout/AppLayoutContainer.js @@ -42,6 +42,7 @@ export default @inject('stores', 'actions') @observer class AppLayoutContainer e setActive, handleIPCMessage, setWebviewReference, + detachService, openWindow, reorder, reload, @@ -105,6 +106,7 @@ export default @inject('stores', 'actions') @observer class AppLayoutContainer e services={services.allDisplayedUnordered} handleIPCMessage={handleIPCMessage} setWebviewReference={setWebviewReference} + detachService={detachService} openWindow={openWindow} reload={reload} openSettings={openSettings} @@ -160,6 +162,7 @@ AppLayoutContainer.wrappedComponent.propTypes = { toggleAudio: PropTypes.func.isRequired, handleIPCMessage: PropTypes.func.isRequired, setWebviewReference: PropTypes.func.isRequired, + detachService: PropTypes.func.isRequired, openWindow: PropTypes.func.isRequired, reloadUpdatedServices: PropTypes.func.isRequired, updateService: PropTypes.func.isRequired, diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index c63bef196..69e616f0c 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -44,6 +44,7 @@ export default class ServicesStore extends Store { this.actions.service.deleteService.listen(this._deleteService.bind(this)); this.actions.service.clearCache.listen(this._clearCache.bind(this)); this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this)); + this.actions.service.detachService.listen(this._detachService.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)); @@ -341,6 +342,11 @@ export default class ServicesStore extends Store { service.isAttached = true; } + @action _detachService({ service }) { + service.webview = null; + service.isAttached = false; + } + @action _focusService({ serviceId }) { const service = this.one(serviceId); -- cgit v1.2.3-70-g09d2 From 6fb07bcb716af76ec2e96345f37624d12d0d1af0 Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Tue, 12 Mar 2019 21:36:10 +0100 Subject: implements basic release announcement feature --- .eslintrc | 2 +- package-lock.json | 5 ++ package.json | 1 + src/actions/index.js | 6 +- src/actions/service.js | 1 + src/components/layout/AppLayout.js | 4 + src/config.js | 1 + src/containers/layout/AppLayoutContainer.js | 2 + src/features/announcements/Component.js | 77 ++++++++++++++++++ src/features/announcements/actions.js | 8 ++ src/features/announcements/api.js | 19 +++++ src/features/announcements/index.js | 37 +++++++++ src/features/announcements/state.js | 17 ++++ src/features/announcements/store.js | 95 ++++++++++++++++++++++ src/i18n/locales/defaultMessages.json | 95 ++++++++++++++-------- src/i18n/locales/en-US.json | 2 + .../messages/src/components/layout/AppLayout.json | 24 +++--- .../src/features/announcements/Component.json | 15 ++++ src/i18n/messages/src/lib/Menu.json | 53 +++++++----- src/lib/Menu.js | 10 +++ src/stores/FeaturesStore.js | 2 + src/stores/ServicesStore.js | 6 ++ 22 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 src/features/announcements/Component.js create mode 100644 src/features/announcements/actions.js create mode 100644 src/features/announcements/api.js create mode 100644 src/features/announcements/index.js create mode 100644 src/features/announcements/state.js create mode 100644 src/features/announcements/store.js create mode 100644 src/i18n/messages/src/features/announcements/Component.json (limited to 'src/stores/ServicesStore.js') diff --git a/.eslintrc b/.eslintrc index 743946d35..a4ffd505c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,7 +14,7 @@ "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], - "react/forbid-prop-types": 1, + "react/forbid-prop-types": 0, "react/destructuring-assignment": 1, "prefer-destructuring": 1, "no-underscore-dangle": 0, diff --git a/package-lock.json b/package-lock.json index 8499abda9..bc333ae50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12094,6 +12094,11 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.6.1.tgz", + "integrity": "sha512-+H0L3ibcWhAZE02SKMqmvYsErLo4EAVJxu5h3bHBBDvvjeWXtl92rGUSBYHL2++5Y+RSNgl8dYOAXcYe7lp1fA==" + }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", diff --git a/package.json b/package.json index 14e0df7ca..4ddc83777 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "hex-to-rgba": "1.0.2", "jsonwebtoken": "^7.4.1", "lodash": "^4.17.4", + "marked": "0.6.1", "mdi": "^1.9.33", "mime-types": "2.1.21", "mobx": "5.7.0", diff --git a/src/actions/index.js b/src/actions/index.js index 59acabb0b..dc1d3b6b2 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -11,6 +11,7 @@ import payment from './payment'; import news from './news'; import settings from './settings'; import requests from './requests'; +import announcements from '../features/announcements/actions'; const actions = Object.assign({}, { service, @@ -25,4 +26,7 @@ const actions = Object.assign({}, { requests, }); -export default defineActions(actions, PropTypes.checkPropTypes); +export default Object.assign( + defineActions(actions, PropTypes.checkPropTypes), + { announcements }, +); diff --git a/src/actions/service.js b/src/actions/service.js index ceaabc31e..ce62560a9 100644 --- a/src/actions/service.js +++ b/src/actions/service.js @@ -5,6 +5,7 @@ export default { setActive: { serviceId: PropTypes.string.isRequired, }, + blurActive: {}, setActiveNext: {}, setActivePrev: {}, showAddServiceInterface: { diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js index 593149e72..2bda91f73 100644 --- a/src/components/layout/AppLayout.js +++ b/src/components/layout/AppLayout.js @@ -13,6 +13,7 @@ import ErrorBoundary from '../util/ErrorBoundary'; // import globalMessages from '../../i18n/globalMessages'; import { isWindows } from '../../environment'; +import AnnouncementScreen from '../../features/announcements/Component'; function createMarkup(HTMLString) { return { __html: HTMLString }; @@ -64,6 +65,7 @@ export default @observer class AppLayout extends Component { areRequiredRequestsLoading: PropTypes.bool.isRequired, darkMode: PropTypes.bool.isRequired, isDelayAppScreenVisible: PropTypes.bool.isRequired, + isAnnouncementVisible: PropTypes.bool.isRequired, }; static defaultProps = { @@ -93,6 +95,7 @@ export default @observer class AppLayout extends Component { areRequiredRequestsLoading, darkMode, isDelayAppScreenVisible, + isAnnouncementVisible, } = this.props; const { intl } = this.context; @@ -166,6 +169,7 @@ export default @observer class AppLayout extends Component { {isDelayAppScreenVisible && ()} + {isAnnouncementVisible && ()} {services} diff --git a/src/config.js b/src/config.js index 479572edb..47d22ca7d 100644 --- a/src/config.js +++ b/src/config.js @@ -41,6 +41,7 @@ export const DEFAULT_FEATURES_CONFIG = { }, isServiceProxyEnabled: false, isServiceProxyPremiumFeature: true, + isAnnouncementsEnabled: true, }; export const DEFAULT_WINDOW_OPTIONS = { diff --git a/src/containers/layout/AppLayoutContainer.js b/src/containers/layout/AppLayoutContainer.js index 5a05ce431..f26e51517 100644 --- a/src/containers/layout/AppLayoutContainer.js +++ b/src/containers/layout/AppLayoutContainer.js @@ -20,6 +20,7 @@ import Services from '../../components/services/content/Services'; import AppLoader from '../../components/ui/AppLoader'; import { state as delayAppState } from '../../features/delayApp'; +import { announcementsState } from '../../features/announcements/state'; export default @inject('stores', 'actions') @observer class AppLayoutContainer extends Component { static defaultProps = { @@ -134,6 +135,7 @@ export default @inject('stores', 'actions') @observer class AppLayoutContainer e areRequiredRequestsLoading={requests.areRequiredRequestsLoading} darkMode={settings.all.app.darkMode} isDelayAppScreenVisible={delayAppState.isDelayAppScreenVisible} + isAnnouncementVisible={announcementsState.isAnnouncementVisible} > {React.Children.count(children) > 0 ? children : null} diff --git a/src/features/announcements/Component.js b/src/features/announcements/Component.js new file mode 100644 index 000000000..5d95f5d84 --- /dev/null +++ b/src/features/announcements/Component.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import marked from 'marked'; +import PropTypes from 'prop-types'; +import { inject, observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import injectSheet from 'react-jss'; +import { themeSidebarWidth } from '@meetfranz/theme/lib/themes/legacy'; +import state from './state'; + +const messages = defineMessages({ + headline: { + id: 'feature.announcements.headline', + defaultMessage: '!!!What\'s new in Franz {version}?', + }, +}); + +const styles = theme => ({ + container: { + background: theme.colorBackground, + position: 'absolute', + top: 0, + zIndex: 140, + width: `calc(100% - ${themeSidebarWidth})`, + display: 'flex', + 'flex-direction': 'column', + 'align-items': 'center', + 'justify-content': 'center', + }, + headline: { + color: theme.colorHeadline, + margin: [25, 0, 40], + 'max-width': 500, + 'text-align': 'center', + 'line-height': '1.3em', + }, + body: { + '& h3': { + fontSize: '24px', + margin: '1.5em 0 1em 0', + }, + '& li': { + marginBottom: '1em', + }, + }, +}); + + +@inject('actions') @injectSheet(styles) @observer +class AnnouncementScreen extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { classes } = this.props; + const { intl } = this.context; + return ( +
+

+ {intl.formatMessage(messages.headline, { version: state.currentVersion })} +

+
+
+ ); + } +} + +export default AnnouncementScreen; diff --git a/src/features/announcements/actions.js b/src/features/announcements/actions.js new file mode 100644 index 000000000..68b262ded --- /dev/null +++ b/src/features/announcements/actions.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; +import { createActionsFromDefinitions } from '../../actions/lib/actions'; + +export const announcementActions = createActionsFromDefinitions({ + show: {}, +}, PropTypes.checkPropTypes); + +export default announcementActions; diff --git a/src/features/announcements/api.js b/src/features/announcements/api.js new file mode 100644 index 000000000..ec16066a6 --- /dev/null +++ b/src/features/announcements/api.js @@ -0,0 +1,19 @@ +import { remote } from 'electron'; + +const debug = require('debug')('Franz:feature:announcements:api'); + +export default { + async getCurrentVersion() { + debug('getting current version of electron app'); + return Promise.resolve(remote.app.getVersion()); + }, + + async getAnnouncementForVersion(version) { + debug('fetching release announcement from Github'); + const url = `https://api.github.com/repos/meetfranz/franz/releases/tags/v${version}`; + const request = await window.fetch(url, { method: 'GET' }); + if (!request.ok) throw request; + const data = await request.json(); + return data.body; + }, +}; diff --git a/src/features/announcements/index.js b/src/features/announcements/index.js new file mode 100644 index 000000000..5ea74e0af --- /dev/null +++ b/src/features/announcements/index.js @@ -0,0 +1,37 @@ +import { reaction, runInAction } from 'mobx'; +import { AnnouncementsStore } from './store'; +import api from './api'; +import state, { resetState } from './state'; + +const debug = require('debug')('Franz:feature:announcements'); + +let store = null; + +export default function initAnnouncements(stores, actions) { + // const { features } = stores; + + // Toggle workspace feature + reaction( + () => ( + true + // features.features.isAnnouncementsEnabled + ), + (isEnabled) => { + if (isEnabled) { + debug('Initializing `announcements` feature'); + store = new AnnouncementsStore(stores, api, actions, state); + store.initialize(); + runInAction(() => { state.isFeatureActive = true; }); + } else if (store) { + debug('Disabling `announcements` feature'); + runInAction(() => { state.isFeatureActive = false; }); + store.teardown(); + store = null; + resetState(); // Reset state to default + } + }, + { + fireImmediately: true, + }, + ); +} diff --git a/src/features/announcements/state.js b/src/features/announcements/state.js new file mode 100644 index 000000000..81b632253 --- /dev/null +++ b/src/features/announcements/state.js @@ -0,0 +1,17 @@ +import { observable } from 'mobx'; + +const defaultState = { + announcement: null, + currentVersion: null, + lastUsedVersion: null, + isAnnouncementVisible: false, + isFeatureActive: false, +}; + +export const announcementsState = observable(defaultState); + +export function resetState() { + Object.assign(announcementsState, defaultState); +} + +export default announcementsState; diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js new file mode 100644 index 000000000..004a44062 --- /dev/null +++ b/src/features/announcements/store.js @@ -0,0 +1,95 @@ +import { action, observable, reaction } from 'mobx'; +import semver from 'semver'; + +import Request from '../../stores/lib/Request'; +import Store from '../../stores/lib/Store'; + +const debug = require('debug')('Franz:feature:announcements:store'); + +export class AnnouncementsStore extends Store { + @observable getCurrentVersion = new Request(this.api, 'getCurrentVersion'); + + @observable getAnnouncement = new Request(this.api, 'getAnnouncementForVersion'); + + constructor(stores, api, actions, state) { + super(stores, api, actions); + this.state = state; + } + + async setup() { + await this.fetchLastUsedVersion(); + await this.fetchCurrentVersion(); + await this.fetchReleaseAnnouncement(); + this.showAnnouncementIfNotSeenYet(); + + this.actions.announcements.show.listen(this._showAnnouncement.bind(this)); + } + + // ====== PUBLIC ====== + + async fetchLastUsedVersion() { + debug('getting last used version from local storage'); + const lastUsedVersion = window.localStorage.getItem('lastUsedVersion'); + this._setLastUsedVersion(lastUsedVersion == null ? '0.0.0' : lastUsedVersion); + } + + async fetchCurrentVersion() { + debug('getting current version from api'); + const version = await this.getCurrentVersion.execute(); + this._setCurrentVersion(version); + } + + async fetchReleaseAnnouncement() { + debug('getting release announcement from api'); + try { + const announcement = await this.getAnnouncement.execute(this.state.currentVersion); + this._setAnnouncement(announcement); + } catch (error) { + this._setAnnouncement(null); + } + } + + showAnnouncementIfNotSeenYet() { + const { announcement, currentVersion, lastUsedVersion } = this.state; + if (announcement && semver.gt(currentVersion, lastUsedVersion)) { + debug(`${currentVersion} < ${lastUsedVersion}: announcement is shown`); + this._showAnnouncement(); + } else { + debug(`${currentVersion} >= ${lastUsedVersion}: announcement is hidden`); + this._hideAnnouncement(); + } + } + + // ====== PRIVATE ====== + + @action _setCurrentVersion(version) { + debug(`setting current version to ${version}`); + this.state.currentVersion = version; + } + + @action _setLastUsedVersion(version) { + debug(`setting last used version to ${version}`); + this.state.lastUsedVersion = version; + } + + @action _setAnnouncement(announcement) { + debug(`setting announcement to ${announcement}`); + this.state.announcement = announcement; + } + + @action _showAnnouncement() { + this.state.isAnnouncementVisible = true; + this.actions.service.blurActive(); + const dispose = reaction( + () => this.stores.services.active, + () => { + this._hideAnnouncement(); + dispose(); + }, + ); + } + + @action _hideAnnouncement() { + this.state.isAnnouncementVisible = false; + } +} diff --git a/src/i18n/locales/defaultMessages.json b/src/i18n/locales/defaultMessages.json index 0641c510c..fcd24c7ef 100644 --- a/src/i18n/locales/defaultMessages.json +++ b/src/i18n/locales/defaultMessages.json @@ -625,78 +625,78 @@ "defaultMessage": "!!!Your services have been updated.", "end": { "column": 3, - "line": 25 + "line": 26 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.servicesUpdated", "start": { "column": 19, - "line": 22 + "line": 23 } }, { "defaultMessage": "!!!A new update for Franz is available.", "end": { "column": 3, - "line": 29 + "line": 30 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.updateAvailable", "start": { "column": 19, - "line": 26 + "line": 27 } }, { "defaultMessage": "!!!Reload services", "end": { "column": 3, - "line": 33 + "line": 34 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.buttonReloadServices", "start": { "column": 24, - "line": 30 + "line": 31 } }, { "defaultMessage": "!!!Changelog", "end": { "column": 3, - "line": 37 + "line": 38 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.buttonChangelog", "start": { "column": 13, - "line": 34 + "line": 35 } }, { "defaultMessage": "!!!Restart & install update", "end": { "column": 3, - "line": 41 + "line": 42 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.buttonInstallUpdate", "start": { "column": 23, - "line": 38 + "line": 39 } }, { "defaultMessage": "!!!Could not load services and user information", "end": { "column": 3, - "line": 45 + "line": 46 }, "file": "src/components/layout/AppLayout.js", "id": "infobar.requiredRequestsFailed", "start": { "column": 26, - "line": 42 + "line": 43 } } ], @@ -3022,6 +3022,24 @@ ], "path": "src/containers/settings/EditUserScreen.json" }, + { + "descriptors": [ + { + "defaultMessage": "!!!What's new in Franz {version}?", + "end": { + "column": 3, + "line": 14 + }, + "file": "src/features/announcements/Component.js", + "id": "feature.announcements.headline", + "start": { + "column": 12, + "line": 11 + } + } + ], + "path": "src/features/announcements/Component.json" + }, { "descriptors": [ { @@ -3799,133 +3817,146 @@ } }, { - "defaultMessage": "!!!Settings", + "defaultMessage": "!!!What's new in Franz?", "end": { "column": 3, "line": 161 }, "file": "src/lib/Menu.js", + "id": "menu.app.announcement", + "start": { + "column": 16, + "line": 158 + } + }, + { + "defaultMessage": "!!!Settings", + "end": { + "column": 3, + "line": 165 + }, + "file": "src/lib/Menu.js", "id": "menu.app.settings", "start": { "column": 12, - "line": 158 + "line": 162 } }, { "defaultMessage": "!!!Hide", "end": { "column": 3, - "line": 165 + "line": 169 }, "file": "src/lib/Menu.js", "id": "menu.app.hide", "start": { "column": 8, - "line": 162 + "line": 166 } }, { "defaultMessage": "!!!Hide Others", "end": { "column": 3, - "line": 169 + "line": 173 }, "file": "src/lib/Menu.js", "id": "menu.app.hideOthers", "start": { "column": 14, - "line": 166 + "line": 170 } }, { "defaultMessage": "!!!Unhide", "end": { "column": 3, - "line": 173 + "line": 177 }, "file": "src/lib/Menu.js", "id": "menu.app.unhide", "start": { "column": 10, - "line": 170 + "line": 174 } }, { "defaultMessage": "!!!Quit", "end": { "column": 3, - "line": 177 + "line": 181 }, "file": "src/lib/Menu.js", "id": "menu.app.quit", "start": { "column": 8, - "line": 174 + "line": 178 } }, { "defaultMessage": "!!!Add New Service...", "end": { "column": 3, - "line": 181 + "line": 185 }, "file": "src/lib/Menu.js", "id": "menu.services.addNewService", "start": { "column": 17, - "line": 178 + "line": 182 } }, { "defaultMessage": "!!!Activate next service...", "end": { "column": 3, - "line": 185 + "line": 189 }, "file": "src/lib/Menu.js", "id": "menu.services.setNextServiceActive", "start": { "column": 23, - "line": 182 + "line": 186 } }, { "defaultMessage": "!!!Activate previous service...", "end": { "column": 3, - "line": 189 + "line": 193 }, "file": "src/lib/Menu.js", "id": "menu.services.activatePreviousService", "start": { "column": 27, - "line": 186 + "line": 190 } }, { "defaultMessage": "!!!Disable notifications & audio", "end": { "column": 3, - "line": 193 + "line": 197 }, "file": "src/lib/Menu.js", "id": "sidebar.muteApp", "start": { "column": 11, - "line": 190 + "line": 194 } }, { "defaultMessage": "!!!Enable notifications & audio", "end": { "column": 3, - "line": 197 + "line": 201 }, "file": "src/lib/Menu.js", "id": "sidebar.unmuteApp", "start": { "column": 13, - "line": 194 + "line": 198 } } ], diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 7543d38bd..573231c45 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -1,6 +1,7 @@ { "app.errorHandler.action": "Reload", "app.errorHandler.headline": "Something went wrong", + "feature.announcements.headline": "What's new in Franz {version}?", "feature.delayApp.action": "Get a Franz Supporter License", "feature.delayApp.headline": "Please purchase a Franz Supporter License to skip waiting", "feature.delayApp.text": "Franz will continue in {seconds} seconds.", @@ -43,6 +44,7 @@ "login.submit.label": "Sign in", "login.tokenExpired": "Your session expired, please login again.", "menu.app.about": "About Franz", + "menu.app.announcement": "What's new in Franz?", "menu.app.hide": "Hide", "menu.app.hideOthers": "Hide Others", "menu.app.quit": "Quit", diff --git a/src/i18n/messages/src/components/layout/AppLayout.json b/src/i18n/messages/src/components/layout/AppLayout.json index 07603d062..384d4b441 100644 --- a/src/i18n/messages/src/components/layout/AppLayout.json +++ b/src/i18n/messages/src/components/layout/AppLayout.json @@ -4,11 +4,11 @@ "defaultMessage": "!!!Your services have been updated.", "file": "src/components/layout/AppLayout.js", "start": { - "line": 22, + "line": 23, "column": 19 }, "end": { - "line": 25, + "line": 26, "column": 3 } }, @@ -17,11 +17,11 @@ "defaultMessage": "!!!A new update for Franz is available.", "file": "src/components/layout/AppLayout.js", "start": { - "line": 26, + "line": 27, "column": 19 }, "end": { - "line": 29, + "line": 30, "column": 3 } }, @@ -30,11 +30,11 @@ "defaultMessage": "!!!Reload services", "file": "src/components/layout/AppLayout.js", "start": { - "line": 30, + "line": 31, "column": 24 }, "end": { - "line": 33, + "line": 34, "column": 3 } }, @@ -43,11 +43,11 @@ "defaultMessage": "!!!Changelog", "file": "src/components/layout/AppLayout.js", "start": { - "line": 34, + "line": 35, "column": 13 }, "end": { - "line": 37, + "line": 38, "column": 3 } }, @@ -56,11 +56,11 @@ "defaultMessage": "!!!Restart & install update", "file": "src/components/layout/AppLayout.js", "start": { - "line": 38, + "line": 39, "column": 23 }, "end": { - "line": 41, + "line": 42, "column": 3 } }, @@ -69,11 +69,11 @@ "defaultMessage": "!!!Could not load services and user information", "file": "src/components/layout/AppLayout.js", "start": { - "line": 42, + "line": 43, "column": 26 }, "end": { - "line": 45, + "line": 46, "column": 3 } } diff --git a/src/i18n/messages/src/features/announcements/Component.json b/src/i18n/messages/src/features/announcements/Component.json new file mode 100644 index 000000000..18e1b84c5 --- /dev/null +++ b/src/i18n/messages/src/features/announcements/Component.json @@ -0,0 +1,15 @@ +[ + { + "id": "feature.announcements.headline", + "defaultMessage": "!!!What's new in Franz {version}?", + "file": "src/features/announcements/Component.js", + "start": { + "line": 11, + "column": 12 + }, + "end": { + "line": 14, + "column": 3 + } + } +] \ No newline at end of file diff --git a/src/i18n/messages/src/lib/Menu.json b/src/i18n/messages/src/lib/Menu.json index 9314f5cce..0db994871 100644 --- a/src/i18n/messages/src/lib/Menu.json +++ b/src/i18n/messages/src/lib/Menu.json @@ -480,16 +480,29 @@ "column": 3 } }, + { + "id": "menu.app.announcement", + "defaultMessage": "!!!What's new in Franz?", + "file": "src/lib/Menu.js", + "start": { + "line": 158, + "column": 16 + }, + "end": { + "line": 161, + "column": 3 + } + }, { "id": "menu.app.settings", "defaultMessage": "!!!Settings", "file": "src/lib/Menu.js", "start": { - "line": 158, + "line": 162, "column": 12 }, "end": { - "line": 161, + "line": 165, "column": 3 } }, @@ -498,11 +511,11 @@ "defaultMessage": "!!!Hide", "file": "src/lib/Menu.js", "start": { - "line": 162, + "line": 166, "column": 8 }, "end": { - "line": 165, + "line": 169, "column": 3 } }, @@ -511,11 +524,11 @@ "defaultMessage": "!!!Hide Others", "file": "src/lib/Menu.js", "start": { - "line": 166, + "line": 170, "column": 14 }, "end": { - "line": 169, + "line": 173, "column": 3 } }, @@ -524,11 +537,11 @@ "defaultMessage": "!!!Unhide", "file": "src/lib/Menu.js", "start": { - "line": 170, + "line": 174, "column": 10 }, "end": { - "line": 173, + "line": 177, "column": 3 } }, @@ -537,11 +550,11 @@ "defaultMessage": "!!!Quit", "file": "src/lib/Menu.js", "start": { - "line": 174, + "line": 178, "column": 8 }, "end": { - "line": 177, + "line": 181, "column": 3 } }, @@ -550,11 +563,11 @@ "defaultMessage": "!!!Add New Service...", "file": "src/lib/Menu.js", "start": { - "line": 178, + "line": 182, "column": 17 }, "end": { - "line": 181, + "line": 185, "column": 3 } }, @@ -563,11 +576,11 @@ "defaultMessage": "!!!Activate next service...", "file": "src/lib/Menu.js", "start": { - "line": 182, + "line": 186, "column": 23 }, "end": { - "line": 185, + "line": 189, "column": 3 } }, @@ -576,11 +589,11 @@ "defaultMessage": "!!!Activate previous service...", "file": "src/lib/Menu.js", "start": { - "line": 186, + "line": 190, "column": 27 }, "end": { - "line": 189, + "line": 193, "column": 3 } }, @@ -589,11 +602,11 @@ "defaultMessage": "!!!Disable notifications & audio", "file": "src/lib/Menu.js", "start": { - "line": 190, + "line": 194, "column": 11 }, "end": { - "line": 193, + "line": 197, "column": 3 } }, @@ -602,11 +615,11 @@ "defaultMessage": "!!!Enable notifications & audio", "file": "src/lib/Menu.js", "start": { - "line": 194, + "line": 198, "column": 13 }, "end": { - "line": 197, + "line": 201, "column": 3 } } diff --git a/src/lib/Menu.js b/src/lib/Menu.js index 7a60c448f..70f3b2877 100644 --- a/src/lib/Menu.js +++ b/src/lib/Menu.js @@ -155,6 +155,10 @@ const menuItems = defineMessages({ id: 'menu.app.about', defaultMessage: '!!!About Franz', }, + announcement: { + id: 'menu.app.announcement', + defaultMessage: '!!!What\'s new in Franz?', + }, settings: { id: 'menu.app.settings', defaultMessage: '!!!Settings', @@ -589,6 +593,12 @@ export default class FranzMenu { label: intl.formatMessage(menuItems.about), role: 'about', }, + { + label: intl.formatMessage(menuItems.announcement), + click: () => { + this.actions.announcements.show(); + }, + }, { type: 'separator', }, diff --git a/src/stores/FeaturesStore.js b/src/stores/FeaturesStore.js index d2842083c..1c9044b07 100644 --- a/src/stores/FeaturesStore.js +++ b/src/stores/FeaturesStore.js @@ -8,6 +8,7 @@ import spellchecker from '../features/spellchecker'; import serviceProxy from '../features/serviceProxy'; import basicAuth from '../features/basicAuth'; import shareFranz from '../features/shareFranz'; +import announcements from '../features/announcements'; import { DEFAULT_FEATURES_CONFIG } from '../config'; @@ -58,5 +59,6 @@ export default class FeaturesStore extends Store { serviceProxy(this.stores, this.actions); basicAuth(this.stores, this.actions); shareFranz(this.stores, this.actions); + announcements(this.stores, this.actions); } } diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index 69e616f0c..88b0331bf 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -35,6 +35,7 @@ export default class ServicesStore extends Store { // Register action handlers this.actions.service.setActive.listen(this._setActive.bind(this)); + this.actions.service.blurActive.listen(this._blurActive.bind(this)); this.actions.service.setActiveNext.listen(this._setActiveNext.bind(this)); this.actions.service.setActivePrev.listen(this._setActivePrev.bind(this)); this.actions.service.showAddServiceInterface.listen(this._showAddServiceInterface.bind(this)); @@ -298,6 +299,11 @@ export default class ServicesStore extends Store { this._focusActiveService(); } + @action _blurActive() { + if (!this.active) return; + this.active.isActive = false; + } + @action _setActiveNext() { const nextIndex = this._wrapIndex(this.allDisplayed.findIndex(service => service.isActive), 1, this.allDisplayed.length); -- cgit v1.2.3-70-g09d2 From 0af622e6e81a5aee64f839eeadd23b4a62b3cf62 Mon Sep 17 00:00:00 2001 From: Dominik Guzei Date: Sat, 23 Mar 2019 14:15:57 +0100 Subject: refactor state management for workspace feature --- src/components/layout/AppLayout.js | 4 +- src/components/services/content/ServiceView.js | 6 +- src/components/ui/AppLoader/index.js | 4 +- src/containers/layout/AppLayoutContainer.js | 4 +- src/containers/settings/SettingsWindow.js | 4 +- src/features/workspaces/api.js | 51 ++++-- .../workspaces/components/WorkspaceDrawer.js | 11 +- .../components/WorkspaceSwitchingIndicator.js | 4 +- .../workspaces/containers/EditWorkspaceScreen.js | 8 +- .../workspaces/containers/WorkspacesScreen.js | 7 +- src/features/workspaces/index.js | 51 +----- src/features/workspaces/state.js | 18 -- src/features/workspaces/store.js | 196 +++++++++++++-------- src/lib/Menu.js | 6 +- src/stores/ServicesStore.js | 6 +- 15 files changed, 199 insertions(+), 181 deletions(-) delete mode 100644 src/features/workspaces/state.js (limited to 'src/stores/ServicesStore.js') diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js index 4dd5ff686..0c72c1413 100644 --- a/src/components/layout/AppLayout.js +++ b/src/components/layout/AppLayout.js @@ -14,8 +14,8 @@ import ErrorBoundary from '../util/ErrorBoundary'; // import globalMessages from '../../i18n/globalMessages'; import { isWindows } from '../../environment'; -import { workspacesState } from '../../features/workspaces/state'; import WorkspaceSwitchingIndicator from '../../features/workspaces/components/WorkspaceSwitchingIndicator'; +import { workspaceStore } from '../../features/workspaces'; function createMarkup(HTMLString) { return { __html: HTMLString }; @@ -53,7 +53,7 @@ const styles = theme => ({ width: `calc(100% + ${theme.workspaceDrawerWidth}px)`, transition: 'transform 0.5s ease', transform() { - return workspacesState.isWorkspaceDrawerOpen ? 'translateX(0)' : `translateX(-${theme.workspaceDrawerWidth}px)`; + return workspaceStore.isWorkspaceDrawerOpen ? 'translateX(0)' : `translateX(-${theme.workspaceDrawerWidth}px)`; }, }, }); diff --git a/src/components/services/content/ServiceView.js b/src/components/services/content/ServiceView.js index ada920cb6..13148b9b3 100644 --- a/src/components/services/content/ServiceView.js +++ b/src/components/services/content/ServiceView.js @@ -35,12 +35,13 @@ export default @observer class ServiceView extends Component { autorunDisposer = null; + forceRepaintTimeout = null; + componentDidMount() { this.autorunDisposer = autorun(() => { - if (!this.isMounted) return; if (this.props.service.isActive) { this.setState({ forceRepaint: true }); - setTimeout(() => { + this.forceRepaintTimeout = setTimeout(() => { this.setState({ forceRepaint: false }); }, 100); } @@ -49,6 +50,7 @@ export default @observer class ServiceView extends Component { componentWillUnmount() { this.autorunDisposer(); + clearTimeout(this.forceRepaintTimeout); } updateTargetUrl = (event) => { diff --git a/src/components/ui/AppLoader/index.js b/src/components/ui/AppLoader/index.js index 61053f6d1..b0c7fed7b 100644 --- a/src/components/ui/AppLoader/index.js +++ b/src/components/ui/AppLoader/index.js @@ -23,11 +23,11 @@ export default @injectSheet(styles) @withTheme class AppLoader extends Component static propTypes = { classes: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, - } + }; state = { step: 0, - } + }; interval = null; diff --git a/src/containers/layout/AppLayoutContainer.js b/src/containers/layout/AppLayoutContainer.js index 772458eab..4329c3097 100644 --- a/src/containers/layout/AppLayoutContainer.js +++ b/src/containers/layout/AppLayoutContainer.js @@ -20,9 +20,9 @@ import Services from '../../components/services/content/Services'; import AppLoader from '../../components/ui/AppLoader'; import { state as delayAppState } from '../../features/delayApp'; -import { workspacesState } from '../../features/workspaces/state'; import { workspaceActions } from '../../features/workspaces/actions'; import WorkspaceDrawer from '../../features/workspaces/components/WorkspaceDrawer'; +import { workspaceStore } from '../../features/workspaces'; export default @inject('stores', 'actions') @observer class AppLayoutContainer extends Component { static defaultProps = { @@ -108,7 +108,7 @@ export default @inject('stores', 'actions') @observer class AppLayoutContainer e updateService={updateService} toggleMuteApp={toggleMuteApp} toggleWorkspaceDrawer={workspaceActions.toggleWorkspaceDrawer} - isWorkspaceDrawerOpen={workspacesState.isWorkspaceDrawerOpen} + isWorkspaceDrawerOpen={workspaceStore.isWorkspaceDrawerOpen} showMessageBadgeWhenMutedSetting={settings.all.app.showMessageBadgeWhenMuted} showMessageBadgesEvenWhenMuted={ui.showMessageBadgesEvenWhenMuted} /> diff --git a/src/containers/settings/SettingsWindow.js b/src/containers/settings/SettingsWindow.js index 8cbde24c1..663b9e2e4 100644 --- a/src/containers/settings/SettingsWindow.js +++ b/src/containers/settings/SettingsWindow.js @@ -7,7 +7,7 @@ import ServicesStore from '../../stores/ServicesStore'; import Layout from '../../components/settings/SettingsLayout'; import Navigation from '../../components/settings/navigation/SettingsNavigation'; import ErrorBoundary from '../../components/util/ErrorBoundary'; -import { workspacesState } from '../../features/workspaces/state'; +import { workspaceStore } from '../../features/workspaces'; export default @inject('stores', 'actions') @observer class SettingsContainer extends Component { render() { @@ -17,7 +17,7 @@ export default @inject('stores', 'actions') @observer class SettingsContainer ex const navigation = ( ); diff --git a/src/features/workspaces/api.js b/src/features/workspaces/api.js index 733cb5593..4e076d233 100644 --- a/src/features/workspaces/api.js +++ b/src/features/workspaces/api.js @@ -1,39 +1,60 @@ import { pick } from 'lodash'; import { sendAuthRequest } from '../../api/utils/auth'; import { API, API_VERSION } from '../../environment'; +import Request from '../../stores/lib/Request'; +import CachedRequest from '../../stores/lib/CachedRequest'; +import Workspace from './models/Workspace'; -export default { +const debug = require('debug')('Franz:feature:workspaces:api'); + +export const workspaceApi = { getUserWorkspaces: async () => { const url = `${API}/${API_VERSION}/workspace`; - const request = await sendAuthRequest(url, { method: 'GET' }); - if (!request.ok) throw request; - return request.json(); + debug('getUserWorkspaces GET', url); + const result = await sendAuthRequest(url, { method: 'GET' }); + debug('getUserWorkspaces RESULT', result); + if (!result.ok) throw result; + const workspaces = await result.json(); + return workspaces.map(data => new Workspace(data)); }, createWorkspace: async (name) => { const url = `${API}/${API_VERSION}/workspace`; - const request = await sendAuthRequest(url, { + const options = { method: 'POST', body: JSON.stringify({ name }), - }); - if (!request.ok) throw request; - return request.json(); + }; + debug('createWorkspace POST', url, options); + const result = await sendAuthRequest(url, options); + debug('createWorkspace RESULT', result); + if (!result.ok) throw result; + return new Workspace(await result.json()); }, deleteWorkspace: async (workspace) => { const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; - const request = await sendAuthRequest(url, { method: 'DELETE' }); - if (!request.ok) throw request; - return request.json(); + debug('deleteWorkspace DELETE', url); + const result = await sendAuthRequest(url, { method: 'DELETE' }); + debug('deleteWorkspace RESULT', result); + if (!result.ok) throw result; + return (await result.json()).deleted; }, updateWorkspace: async (workspace) => { const url = `${API}/${API_VERSION}/workspace/${workspace.id}`; - const request = await sendAuthRequest(url, { + const options = { method: 'PUT', body: JSON.stringify(pick(workspace, ['name', 'services'])), - }); - if (!request.ok) throw request; - return request.json(); + }; + debug('updateWorkspace UPDATE', url, options); + const result = await sendAuthRequest(url, options); + debug('updateWorkspace RESULT', result); + if (!result.ok) throw result; + return new Workspace(await result.json()); }, }; + +export const getUserWorkspacesRequest = new CachedRequest(workspaceApi, 'getUserWorkspaces'); +export const createWorkspaceRequest = new Request(workspaceApi, 'createWorkspace'); +export const deleteWorkspaceRequest = new Request(workspaceApi, 'deleteWorkspace'); +export const updateWorkspaceRequest = new Request(workspaceApi, 'updateWorkspace'); diff --git a/src/features/workspaces/components/WorkspaceDrawer.js b/src/features/workspaces/components/WorkspaceDrawer.js index c18eb0e11..6dc779be9 100644 --- a/src/features/workspaces/components/WorkspaceDrawer.js +++ b/src/features/workspaces/components/WorkspaceDrawer.js @@ -6,9 +6,9 @@ import { defineMessages, intlShape } from 'react-intl'; import { H1, Icon } from '@meetfranz/ui'; import ReactTooltip from 'react-tooltip'; -import { workspacesState } from '../state'; import WorkspaceDrawerItem from './WorkspaceDrawerItem'; import { workspaceActions } from '../actions'; +import { workspaceStore } from '../index'; const messages = defineMessages({ headline: { @@ -70,7 +70,12 @@ class WorkspaceDrawer extends Component { getServicesForWorkspace, } = this.props; const { intl } = this.context; - const { activeWorkspace, isSwitchingWorkspace, nextWorkspace } = workspacesState; + const { + activeWorkspace, + isSwitchingWorkspace, + nextWorkspace, + workspaces, + } = workspaceStore; const actualWorkspace = isSwitchingWorkspace ? nextWorkspace : activeWorkspace; return (
@@ -95,7 +100,7 @@ class WorkspaceDrawer extends Component { services={getServicesForWorkspace(null)} isActive={actualWorkspace == null} /> - {workspacesState.workspaces.map(workspace => ( + {workspaces.map(workspace => ( { - const { workspaceBeingEdited } = workspacesState; + const { workspaceBeingEdited } = workspaceStore; const { actions } = this.props; if (!workspaceBeingEdited) return null; actions.workspaces.delete({ workspace: workspaceBeingEdited }); }; onSave = (values) => { - const { workspaceBeingEdited } = workspacesState; + const { workspaceBeingEdited } = workspaceStore; const { actions } = this.props; const workspace = new Workspace( Object.assign({}, workspaceBeingEdited, values), @@ -38,7 +38,7 @@ class EditWorkspaceScreen extends Component { }; render() { - const { workspaceBeingEdited } = workspacesState; + const { workspaceBeingEdited } = workspaceStore; const { stores } = this.props; if (!workspaceBeingEdited) return null; return ( diff --git a/src/features/workspaces/containers/WorkspacesScreen.js b/src/features/workspaces/containers/WorkspacesScreen.js index bd1ddcd43..5fdea217e 100644 --- a/src/features/workspaces/containers/WorkspacesScreen.js +++ b/src/features/workspaces/containers/WorkspacesScreen.js @@ -1,9 +1,10 @@ import React, { Component } from 'react'; import { inject, observer } from 'mobx-react'; import PropTypes from 'prop-types'; -import { workspacesState } from '../state'; import WorkspacesDashboard from '../components/WorkspacesDashboard'; import ErrorBoundary from '../../../components/util/ErrorBoundary'; +import { workspaceStore } from '../index'; +import { getUserWorkspacesRequest } from '../api'; @inject('actions') @observer class WorkspacesScreen extends Component { @@ -20,8 +21,8 @@ class WorkspacesScreen extends Component { return ( actions.workspaces.create(data)} onWorkspaceClick={w => actions.workspaces.edit({ workspace: w })} /> diff --git a/src/features/workspaces/index.js b/src/features/workspaces/index.js index 1644c0e2f..68f82bdee 100644 --- a/src/features/workspaces/index.js +++ b/src/features/workspaces/index.js @@ -1,26 +1,9 @@ -import { reaction, runInAction } from 'mobx'; +import { reaction } from 'mobx'; import WorkspacesStore from './store'; -import api from './api'; -import { workspacesState, resetState } from './state'; const debug = require('debug')('Franz:feature:workspaces'); -let store = null; - -export const filterServicesByActiveWorkspace = (services) => { - const { - activeWorkspace, - isFeatureActive, - } = workspacesState; - - if (!isFeatureActive) return services; - if (activeWorkspace) return services.filter(s => activeWorkspace.services.includes(s.id)); - return services; -}; - -export const getActiveWorkspaceServices = services => ( - filterServicesByActiveWorkspace(services) -); +export const workspaceStore = new WorkspacesStore(); export default function initWorkspaces(stores, actions) { const { features, user } = stores; @@ -33,38 +16,16 @@ export default function initWorkspaces(stores, actions) { ) ), (isEnabled) => { - if (isEnabled) { + if (isEnabled && !workspaceStore.isFeatureActive) { debug('Initializing `workspaces` feature'); - store = new WorkspacesStore(stores, api, actions, workspacesState); - store.initialize(); - runInAction(() => { workspacesState.isFeatureActive = true; }); - } else if (store) { + workspaceStore.start(stores, actions); + } else if (workspaceStore.isFeatureActive) { debug('Disabling `workspaces` feature'); - runInAction(() => { workspacesState.isFeatureActive = false; }); - store.teardown(); - store = null; - resetState(); // Reset state to default + workspaceStore.stop(); } }, { fireImmediately: true, }, ); - - // Update active service on workspace switches - reaction(() => ({ - isFeatureActive: workspacesState.isFeatureActive, - activeWorkspace: workspacesState.activeWorkspace, - }), ({ isFeatureActive, activeWorkspace }) => { - if (!isFeatureActive) return; - if (activeWorkspace) { - const services = stores.services.allDisplayed; - const activeService = services.find(s => s.isActive); - const workspaceServices = filterServicesByActiveWorkspace(services); - const isActiveServiceInWorkspace = workspaceServices.includes(activeService); - if (!isActiveServiceInWorkspace) { - actions.service.setActive({ serviceId: workspaceServices[0].id }); - } - } - }); } diff --git a/src/features/workspaces/state.js b/src/features/workspaces/state.js deleted file mode 100644 index c916480c0..000000000 --- a/src/features/workspaces/state.js +++ /dev/null @@ -1,18 +0,0 @@ -import { observable } from 'mobx'; - -const defaultState = { - activeWorkspace: null, - nextWorkspace: null, - isLoadingWorkspaces: false, - isFeatureActive: false, - isSwitchingWorkspace: false, - isWorkspaceDrawerOpen: false, - workspaces: [], - workspaceBeingEdited: null, -}; - -export const workspacesState = observable(defaultState); - -export function resetState() { - Object.assign(workspacesState, defaultState); -} diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js index f6b9b2ff4..883f36ffb 100644 --- a/src/features/workspaces/store.js +++ b/src/features/workspaces/store.js @@ -1,54 +1,39 @@ -import { observable, reaction, action } from 'mobx'; -import Store from '../../stores/lib/Store'; -import CachedRequest from '../../stores/lib/CachedRequest'; -import Workspace from './models/Workspace'; +import { + computed, + observable, + action, +} from 'mobx'; +import Reaction from '../../stores/lib/Reaction'; import { matchRoute } from '../../helpers/routing-helpers'; import { workspaceActions } from './actions'; +import { + createWorkspaceRequest, + deleteWorkspaceRequest, + getUserWorkspacesRequest, + updateWorkspaceRequest, +} from './api'; -const debug = require('debug')('Franz:feature:workspaces'); +const debug = require('debug')('Franz:feature:workspaces:store'); -export default class WorkspacesStore extends Store { - @observable allWorkspacesRequest = new CachedRequest(this.api, 'getUserWorkspaces'); +export default class WorkspacesStore { + @observable isFeatureActive = false; - constructor(stores, api, actions, state) { - super(stores, api, actions); - this.state = state; - } + @observable activeWorkspace = null; + + @observable nextWorkspace = null; + + @observable workspaceBeingEdited = null; + + @observable isSwitchingWorkspace = false; - setup() { - debug('fetching workspaces'); - this.allWorkspacesRequest.execute(); - - /** - * Update the state workspaces array when workspaces request has results. - */ - reaction( - () => this.allWorkspacesRequest.result, - workspaces => this._setWorkspaces(workspaces), - ); - /** - * Update the loading state when workspace request is executing. - */ - reaction( - () => this.allWorkspacesRequest.isExecuting, - isExecuting => this._setIsLoadingWorkspaces(isExecuting), - ); - /** - * Update the state with the workspace to be edited when route matches. - */ - reaction( - () => ({ - pathname: this.stores.router.location.pathname, - workspaces: this.state.workspaces, - }), - ({ pathname }) => { - const match = matchRoute('/settings/workspaces/edit/:id', pathname); - if (match) { - this.state.workspaceBeingEdited = this._getWorkspaceById(match.id); - } - }, - ); + @observable isWorkspaceDrawerOpen = false; + @computed get workspaces() { + return getUserWorkspacesRequest.execute().result || []; + } + + constructor() { + // Wire-up action handlers workspaceActions.edit.listen(this._edit); workspaceActions.create.listen(this._create); workspaceActions.delete.listen(this._delete); @@ -57,28 +42,62 @@ export default class WorkspacesStore extends Store { workspaceActions.deactivate.listen(this._deactivateActiveWorkspace); workspaceActions.toggleWorkspaceDrawer.listen(this._toggleWorkspaceDrawer); workspaceActions.openWorkspaceSettings.listen(this._openWorkspaceSettings); + + // Register and start reactions + this._registerReactions([ + this._updateWorkspaceBeingEdited, + this._updateActiveServiceOnWorkspaceSwitch, + ]); } - _getWorkspaceById = id => this.state.workspaces.find(w => w.id === id); + start(stores, actions) { + debug('WorkspacesStore::start'); + this.stores = stores; + this.actions = actions; + this._reactions.forEach(r => r.start()); + this.isFeatureActive = true; + } - @action _setWorkspaces = (workspaces) => { - debug('setting user workspaces', workspaces.slice()); - this.state.workspaces = workspaces.map(data => new Workspace(data)); - }; + stop() { + debug('WorkspacesStore::stop'); + this._reactions.forEach(r => r.stop()); + this.isFeatureActive = false; + } - @action _setIsLoadingWorkspaces = (isLoading) => { - this.state.isLoadingWorkspaces = isLoading; + filterServicesByActiveWorkspace = (services) => { + const { activeWorkspace, isFeatureActive } = this; + + if (!isFeatureActive) return services; + if (activeWorkspace) { + return services.filter(s => ( + activeWorkspace.services.includes(s.id) + )); + } + return services; }; + // ========== PRIVATE ========= // + + _reactions = []; + + _registerReactions(reactions) { + reactions.forEach(r => this._reactions.push(new Reaction(r))); + } + + _getWorkspaceById = id => this.workspaces.find(w => w.id === id); + + // Actions + @action _edit = ({ workspace }) => { this.stores.router.push(`/settings/workspaces/edit/${workspace.id}`); }; @action _create = async ({ name }) => { try { - const result = await this.api.createWorkspace(name); - const workspace = new Workspace(result); - this.state.workspaces.push(workspace); + const workspace = await createWorkspaceRequest.execute(name); + await getUserWorkspacesRequest.patch((result) => { + result.push(workspace); + }); this._edit({ workspace }); } catch (error) { throw error; @@ -87,8 +106,10 @@ export default class WorkspacesStore extends Store { @action _delete = async ({ workspace }) => { try { - await this.api.deleteWorkspace(workspace); - this.state.workspaces.remove(workspace); + await deleteWorkspaceRequest.execute(workspace); + await getUserWorkspacesRequest.patch((result) => { + result.remove(workspace); + }); this.stores.router.push('/settings/workspaces'); } catch (error) { throw error; @@ -97,9 +118,11 @@ export default class WorkspacesStore extends Store { @action _update = async ({ workspace }) => { try { - await this.api.updateWorkspace(workspace); - const localWorkspace = this.state.workspaces.find(ws => ws.id === workspace.id); - Object.assign(localWorkspace, workspace); + await updateWorkspaceRequest.execute(workspace); + await getUserWorkspacesRequest.patch((result) => { + const localWorkspace = result.find(ws => ws.id === workspace.id); + Object.assign(localWorkspace, workspace); + }); this.stores.router.push('/settings/workspaces'); } catch (error) { throw error; @@ -107,33 +130,56 @@ export default class WorkspacesStore extends Store { }; @action _setActiveWorkspace = ({ workspace }) => { - Object.assign(this.state, { - isSwitchingWorkspace: true, - nextWorkspace: workspace, - }); - setTimeout(() => { this.state.activeWorkspace = workspace; }, 100); + // Indicate that we are switching to another workspace + this.isSwitchingWorkspace = true; + this.nextWorkspace = workspace; + // Delay switching to next workspace so that the services loading does not drag down UI + setTimeout(() => { this.activeWorkspace = workspace; }, 100); + // Indicate that we are done switching to the next workspace setTimeout(() => { - Object.assign(this.state, { - isSwitchingWorkspace: false, - nextWorkspace: null, - }); + this.isSwitchingWorkspace = false; + this.nextWorkspace = null; }, 1000); }; @action _deactivateActiveWorkspace = () => { - Object.assign(this.state, { - isSwitchingWorkspace: true, - nextWorkspace: null, - }); - setTimeout(() => { this.state.activeWorkspace = null; }, 100); - setTimeout(() => { this.state.isSwitchingWorkspace = false; }, 1000); + // Indicate that we are switching to default workspace + this.isSwitchingWorkspace = true; + this.nextWorkspace = null; + // Delay switching to next workspace so that the services loading does not drag down UI + setTimeout(() => { this.activeWorkspace = null; }, 100); + // Indicate that we are done switching to the default workspace + setTimeout(() => { this.isSwitchingWorkspace = false; }, 1000); }; @action _toggleWorkspaceDrawer = () => { - this.state.isWorkspaceDrawerOpen = !this.state.isWorkspaceDrawerOpen; + this.isWorkspaceDrawerOpen = !this.isWorkspaceDrawerOpen; }; @action _openWorkspaceSettings = () => { this.actions.ui.openSettings({ path: 'workspaces' }); }; + + // Reactions + + _updateWorkspaceBeingEdited = () => { + const { pathname } = this.stores.router.location; + const match = matchRoute('/settings/workspaces/edit/:id', pathname); + if (match) { + this.workspaceBeingEdited = this._getWorkspaceById(match.id); + } + }; + + _updateActiveServiceOnWorkspaceSwitch = () => { + if (!this.isFeatureActive) return; + if (this.activeWorkspace) { + const services = this.stores.services.allDisplayed; + const activeService = services.find(s => s.isActive); + const workspaceServices = this.filterServicesByActiveWorkspace(services); + const isActiveServiceInWorkspace = workspaceServices.includes(activeService); + if (!isActiveServiceInWorkspace) { + this.actions.service.setActive({ serviceId: workspaceServices[0].id }); + } + } + }; } diff --git a/src/lib/Menu.js b/src/lib/Menu.js index 2a88515f4..3435e04f7 100644 --- a/src/lib/Menu.js +++ b/src/lib/Menu.js @@ -3,7 +3,7 @@ import { observable, autorun } from 'mobx'; import { defineMessages } from 'react-intl'; import { isMac, ctrlKey, cmdKey } from '../environment'; -import { workspacesState } from '../features/workspaces/state'; +import { workspaceStore } from '../features/workspaces/index'; import { workspaceActions } from '../features/workspaces/actions'; const { app, Menu, dialog } = remote; @@ -784,7 +784,7 @@ export default class FranzMenu { } workspacesMenu() { - const { workspaces, activeWorkspace } = workspacesState; + const { workspaces, activeWorkspace, isWorkspaceDrawerOpen } = workspaceStore; const { intl } = window.franz; const menu = []; @@ -800,7 +800,7 @@ export default class FranzMenu { // Open workspace drawer: const drawerLabel = ( - workspacesState.isWorkspaceDrawerOpen ? menuItems.closeWorkspaceDrawer : menuItems.openWorkspaceDrawer + isWorkspaceDrawerOpen ? menuItems.closeWorkspaceDrawer : menuItems.openWorkspaceDrawer ); menu.push({ label: intl.formatMessage(drawerLabel), diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index cc8eed65b..0ec6bf550 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -12,7 +12,7 @@ import Request from './lib/Request'; import CachedRequest from './lib/CachedRequest'; import { matchRoute } from '../helpers/routing-helpers'; import { gaEvent } from '../lib/analytics'; -import { filterServicesByActiveWorkspace, getActiveWorkspaceServices } from '../features/workspaces'; +import { workspaceStore } from '../features/workspaces'; const debug = require('debug')('Franz:ServiceStore'); @@ -109,7 +109,7 @@ export default class ServicesStore extends Store { @computed get allDisplayed() { const services = this.stores.settings.all.app.showDisabledServices ? this.all : this.enabled; - return filterServicesByActiveWorkspace(services); + return workspaceStore.filterServicesByActiveWorkspace(services); } // This is just used to avoid unnecessary rerendering of resource-heavy webviews @@ -117,7 +117,7 @@ export default class ServicesStore extends Store { const { showDisabledServices } = this.stores.settings.all.app; const services = this.allServicesRequest.execute().result || []; const filteredServices = showDisabledServices ? services : services.filter(service => service.isEnabled); - return getActiveWorkspaceServices(filteredServices); + return workspaceStore.filterServicesByActiveWorkspace(filteredServices); } @computed get filtered() { -- cgit v1.2.3-70-g09d2