aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/announcements/components/AnnouncementScreen.js2
-rw-r--r--src/features/basicAuth/Component.js1
-rw-r--r--src/features/communityRecipes/index.js28
-rw-r--r--src/features/communityRecipes/store.js31
-rw-r--r--src/features/delayApp/Component.js43
-rw-r--r--src/features/delayApp/index.js7
-rw-r--r--src/features/delayApp/styles.js1
-rw-r--r--src/features/serviceLimit/components/LimitReachedInfobox.js78
-rw-r--r--src/features/serviceLimit/index.js33
-rw-r--r--src/features/serviceLimit/store.js41
-rw-r--r--src/features/serviceProxy/index.js8
-rw-r--r--src/features/shareFranz/Component.js3
-rw-r--r--src/features/spellchecker/index.js8
-rw-r--r--src/features/todos/actions.js22
-rw-r--r--src/features/todos/components/TodosWebview.js237
-rw-r--r--src/features/todos/constants.js4
-rw-r--r--src/features/todos/containers/TodosScreen.js32
-rw-r--r--src/features/todos/index.js34
-rw-r--r--src/features/todos/preload.js23
-rw-r--r--src/features/todos/store.js147
-rw-r--r--src/features/workspaces/store.js27
21 files changed, 776 insertions, 34 deletions
diff --git a/src/features/announcements/components/AnnouncementScreen.js b/src/features/announcements/components/AnnouncementScreen.js
index e7c5fe395..03bd5ba41 100644
--- a/src/features/announcements/components/AnnouncementScreen.js
+++ b/src/features/announcements/components/AnnouncementScreen.js
@@ -28,7 +28,7 @@ const smallScreen = '1000px';
28const styles = theme => ({ 28const styles = theme => ({
29 container: { 29 container: {
30 background: theme.colorBackground, 30 background: theme.colorBackground,
31 position: 'absolute', 31 position: 'relative',
32 top: 0, 32 top: 0,
33 zIndex: 140, 33 zIndex: 140,
34 width: '100%', 34 width: '100%',
diff --git a/src/features/basicAuth/Component.js b/src/features/basicAuth/Component.js
index a8252acb7..ba9ae2273 100644
--- a/src/features/basicAuth/Component.js
+++ b/src/features/basicAuth/Component.js
@@ -27,7 +27,6 @@ export default @injectSheet(styles) @observer class BasicAuthModal extends Compo
27 e.preventDefault(); 27 e.preventDefault();
28 28
29 const values = Form.values(); 29 const values = Form.values();
30 console.log('form submit', values);
31 30
32 sendCredentials(values.user, values.password); 31 sendCredentials(values.user, values.password);
33 resetState(); 32 resetState();
diff --git a/src/features/communityRecipes/index.js b/src/features/communityRecipes/index.js
new file mode 100644
index 000000000..4d050f90e
--- /dev/null
+++ b/src/features/communityRecipes/index.js
@@ -0,0 +1,28 @@
1import { reaction } from 'mobx';
2import { CommunityRecipesStore } from './store';
3
4const debug = require('debug')('Franz:feature:communityRecipes');
5
6export const DEFAULT_SERVICE_LIMIT = 3;
7
8export const communityRecipesStore = new CommunityRecipesStore();
9
10export default function initCommunityRecipes(stores, actions) {
11 const { features } = stores;
12
13 communityRecipesStore.start(stores, actions);
14
15 // Toggle communityRecipe premium status
16 reaction(
17 () => (
18 features.features.isCommunityRecipesIncludedInCurrentPlan
19 ),
20 (isPremiumFeature) => {
21 debug('Community recipes is premium feature: ', isPremiumFeature);
22 communityRecipesStore.isCommunityRecipesIncludedInCurrentPlan = isPremiumFeature;
23 },
24 {
25 fireImmediately: true,
26 },
27 );
28}
diff --git a/src/features/communityRecipes/store.js b/src/features/communityRecipes/store.js
new file mode 100644
index 000000000..4d45c3b33
--- /dev/null
+++ b/src/features/communityRecipes/store.js
@@ -0,0 +1,31 @@
1import { computed, observable } from 'mobx';
2import { FeatureStore } from '../utils/FeatureStore';
3
4const debug = require('debug')('Franz:feature:communityRecipes:store');
5
6export class CommunityRecipesStore extends FeatureStore {
7 @observable isCommunityRecipesIncludedInCurrentPlan = false;
8
9 start(stores, actions) {
10 debug('start');
11 this.stores = stores;
12 this.actions = actions;
13 }
14
15 stop() {
16 debug('stop');
17 super.stop();
18 }
19
20 @computed get communityRecipes() {
21 if (!this.stores) return [];
22
23 return this.stores.recipePreviews.dev.map((r) => {
24 r.isDevRecipe = !!r.author.find(a => a.email === this.stores.user.data.email);
25
26 return r;
27 });
28 }
29}
30
31export default CommunityRecipesStore;
diff --git a/src/features/delayApp/Component.js b/src/features/delayApp/Component.js
index ff0f1f2f8..de5653f04 100644
--- a/src/features/delayApp/Component.js
+++ b/src/features/delayApp/Component.js
@@ -4,29 +4,39 @@ import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl'; 4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss'; 5import injectSheet from 'react-jss';
6 6
7import { Button } from '@meetfranz/forms';
7import { gaEvent } from '../../lib/analytics'; 8import { gaEvent } from '../../lib/analytics';
8 9
9import Button from '../../components/ui/Button'; 10// import Button from '../../components/ui/Button';
10 11
11import { config } from '.'; 12import { config } from '.';
12import styles from './styles'; 13import styles from './styles';
14import UserStore from '../../stores/UserStore';
13 15
14const messages = defineMessages({ 16const messages = defineMessages({
15 headline: { 17 headline: {
16 id: 'feature.delayApp.headline', 18 id: 'feature.delayApp.headline',
17 defaultMessage: '!!!Please purchase license to skip waiting', 19 defaultMessage: '!!!Please purchase license to skip waiting',
18 }, 20 },
21 headlineTrial: {
22 id: 'feature.delayApp.trial.headline',
23 defaultMessage: '!!!Get the free Franz Professional 14 day trial and skip the line',
24 },
19 action: { 25 action: {
20 id: 'feature.delayApp.action', 26 id: 'feature.delayApp.upgrade.action',
21 defaultMessage: '!!!Get a Franz Supporter License', 27 defaultMessage: '!!!Get a Franz Supporter License',
22 }, 28 },
29 actionTrial: {
30 id: 'feature.delayApp.trial.action',
31 defaultMessage: '!!!Yes, I want the free 14 day trial of Franz Professional',
32 },
23 text: { 33 text: {
24 id: 'feature.delayApp.text', 34 id: 'feature.delayApp.text',
25 defaultMessage: '!!!Franz will continue in {seconds} seconds.', 35 defaultMessage: '!!!Franz will continue in {seconds} seconds.',
26 }, 36 },
27}); 37});
28 38
29export default @inject('actions') @injectSheet(styles) @observer class DelayApp extends Component { 39export default @inject('stores', 'actions') @injectSheet(styles) @observer class DelayApp extends Component {
30 static propTypes = { 40 static propTypes = {
31 // eslint-disable-next-line 41 // eslint-disable-next-line
32 classes: PropTypes.object.isRequired, 42 classes: PropTypes.object.isRequired,
@@ -62,25 +72,37 @@ export default @inject('actions') @injectSheet(styles) @observer class DelayApp
62 } 72 }
63 73
64 handleCTAClick() { 74 handleCTAClick() {
65 const { actions } = this.props; 75 const { actions, stores } = this.props;
76 const { hadSubscription } = stores.user.data;
77 const { defaultTrialPlan } = stores.features.features;
78
79 if (!hadSubscription) {
80 console.log('directly activate trial');
81 actions.user.activateTrial({ planId: defaultTrialPlan });
66 82
67 actions.ui.openSettings({ path: 'user' }); 83 gaEvent('DelayApp', 'subscribe_click', 'Delay App Feature');
84 } else {
85 actions.ui.openSettings({ path: 'user' });
68 86
69 gaEvent('DelayApp', 'subscribe_click', 'Delay App Feature'); 87 gaEvent('DelayApp', 'subscribe_click', 'Delay App Feature');
88 }
70 } 89 }
71 90
72 render() { 91 render() {
73 const { classes } = this.props; 92 const { classes, stores } = this.props;
74 const { intl } = this.context; 93 const { intl } = this.context;
75 94
95 const { hadSubscription } = stores.user.data;
96
76 return ( 97 return (
77 <div className={`${classes.container}`}> 98 <div className={`${classes.container}`}>
78 <h1 className={classes.headline}>{intl.formatMessage(messages.headline)}</h1> 99 <h1 className={classes.headline}>{intl.formatMessage(hadSubscription ? messages.headline : messages.headlineTrial)}</h1>
79 <Button 100 <Button
80 label={intl.formatMessage(messages.action)} 101 label={intl.formatMessage(hadSubscription ? messages.action : messages.actionTrial)}
81 className={classes.button} 102 className={classes.button}
82 buttonType="inverted" 103 buttonType="inverted"
83 onClick={this.handleCTAClick.bind(this)} 104 onClick={this.handleCTAClick.bind(this)}
105 busy={stores.user.activateTrialRequest.isExecuting}
84 /> 106 />
85 <p className="footnote"> 107 <p className="footnote">
86 {intl.formatMessage(messages.text, { 108 {intl.formatMessage(messages.text, {
@@ -93,6 +115,9 @@ export default @inject('actions') @injectSheet(styles) @observer class DelayApp
93} 115}
94 116
95DelayApp.wrappedComponent.propTypes = { 117DelayApp.wrappedComponent.propTypes = {
118 stores: PropTypes.shape({
119 user: PropTypes.instanceOf(UserStore).isRequired,
120 }).isRequired,
96 actions: PropTypes.shape({ 121 actions: PropTypes.shape({
97 ui: PropTypes.shape({ 122 ui: PropTypes.shape({
98 openSettings: PropTypes.func.isRequired, 123 openSettings: PropTypes.func.isRequired,
diff --git a/src/features/delayApp/index.js b/src/features/delayApp/index.js
index 67f0fc5e6..627537de7 100644
--- a/src/features/delayApp/index.js
+++ b/src/features/delayApp/index.js
@@ -33,7 +33,7 @@ export default function init(stores) {
33 }; 33 };
34 34
35 reaction( 35 reaction(
36 () => stores.user.isLoggedIn && stores.features.features.needToWaitToProceed && !stores.user.data.isPremium, 36 () => stores.user.isLoggedIn && stores.services.allServicesRequest.wasExecuted && stores.features.features.needToWaitToProceed && !stores.user.data.isPremium,
37 (isEnabled) => { 37 (isEnabled) => {
38 if (isEnabled) { 38 if (isEnabled) {
39 debug('Enabling `delayApp` feature'); 39 debug('Enabling `delayApp` feature');
@@ -44,7 +44,8 @@ export default function init(stores) {
44 config.delayDuration = globalConfig.wait !== undefined ? globalConfig.wait : DEFAULT_FEATURES_CONFIG.needToWaitToProceedConfig.wait; 44 config.delayDuration = globalConfig.wait !== undefined ? globalConfig.wait : DEFAULT_FEATURES_CONFIG.needToWaitToProceedConfig.wait;
45 45
46 autorun(() => { 46 autorun(() => {
47 if (stores.services.all.length === 0) { 47 if (stores.services.allDisplayed.length === 0) {
48 debug('seas', stores.services.all.length);
48 shownAfterLaunch = true; 49 shownAfterLaunch = true;
49 return; 50 return;
50 } 51 }
@@ -64,7 +65,7 @@ export default function init(stores) {
64 debug('Resetting app delay'); 65 debug('Resetting app delay');
65 66
66 setVisibility(false); 67 setVisibility(false);
67 }, DEFAULT_FEATURES_CONFIG.needToWaitToProceedConfig.wait + 1000); // timer needs to be able to hit 0 68 }, config.delayDuration + 1000); // timer needs to be able to hit 0
68 } 69 }
69 }); 70 });
70 } else { 71 } else {
diff --git a/src/features/delayApp/styles.js b/src/features/delayApp/styles.js
index 5c214cfdf..69c3c7a27 100644
--- a/src/features/delayApp/styles.js
+++ b/src/features/delayApp/styles.js
@@ -1,7 +1,6 @@
1export default theme => ({ 1export default theme => ({
2 container: { 2 container: {
3 background: theme.colorBackground, 3 background: theme.colorBackground,
4 position: 'absolute',
5 top: 0, 4 top: 0,
6 width: '100%', 5 width: '100%',
7 display: 'flex', 6 display: 'flex',
diff --git a/src/features/serviceLimit/components/LimitReachedInfobox.js b/src/features/serviceLimit/components/LimitReachedInfobox.js
new file mode 100644
index 000000000..fc54dcf85
--- /dev/null
+++ b/src/features/serviceLimit/components/LimitReachedInfobox.js
@@ -0,0 +1,78 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss';
6import { Infobox } from '@meetfranz/ui';
7
8import { gaEvent } from '../../../lib/analytics';
9
10const messages = defineMessages({
11 limitReached: {
12 id: 'feature.serviceLimit.limitReached',
13 defaultMessage: '!!!You have added {amount} of {limit} services. Please upgrade your account to add more services.',
14 },
15 action: {
16 id: 'premiumFeature.button.upgradeAccount',
17 defaultMessage: '!!!Upgrade account',
18 },
19});
20
21const styles = theme => ({
22 container: {
23 height: 'auto',
24 background: theme.styleTypes.primary.accent,
25 color: theme.styleTypes.primary.contrast,
26 borderRadius: 0,
27 marginBottom: 0,
28
29 '& > div': {
30 marginBottom: 0,
31 },
32
33 '& button': {
34 color: theme.styleTypes.primary.contrast,
35 },
36 },
37});
38
39
40@inject('stores', 'actions') @injectSheet(styles) @observer
41class LimitReachedInfobox extends Component {
42 static propTypes = {
43 classes: PropTypes.object.isRequired,
44 stores: PropTypes.object.isRequired,
45 actions: PropTypes.object.isRequired,
46 };
47
48 static contextTypes = {
49 intl: intlShape,
50 };
51
52 render() {
53 const { classes, stores, actions } = this.props;
54 const { intl } = this.context;
55
56 const {
57 serviceLimit,
58 } = stores;
59
60 if (!serviceLimit.userHasReachedServiceLimit) return null;
61
62 return (
63 <Infobox
64 icon="mdiInformation"
65 className={classes.container}
66 ctaLabel={intl.formatMessage(messages.action)}
67 ctaOnClick={() => {
68 actions.ui.openSettings({ path: 'user' });
69 gaEvent('Service Limit', 'upgrade', 'Upgrade account');
70 }}
71 >
72 {intl.formatMessage(messages.limitReached, { amount: serviceLimit.serviceCount, limit: serviceLimit.serviceLimit })}
73 </Infobox>
74 );
75 }
76}
77
78export default LimitReachedInfobox;
diff --git a/src/features/serviceLimit/index.js b/src/features/serviceLimit/index.js
new file mode 100644
index 000000000..92ad8bb98
--- /dev/null
+++ b/src/features/serviceLimit/index.js
@@ -0,0 +1,33 @@
1import { reaction } from 'mobx';
2import { ServiceLimitStore } from './store';
3
4const debug = require('debug')('Franz:feature:serviceLimit');
5
6export const DEFAULT_SERVICE_LIMIT = 3;
7
8let store = null;
9
10export const serviceLimitStore = new ServiceLimitStore();
11
12export default function initServiceLimit(stores, actions) {
13 const { features } = stores;
14
15 // Toggle serviceLimit feature
16 reaction(
17 () => (
18 features.features.isServiceLimitEnabled
19 ),
20 (isEnabled) => {
21 if (isEnabled) {
22 debug('Initializing `serviceLimit` feature');
23 store = serviceLimitStore.start(stores, actions);
24 } else if (store) {
25 debug('Disabling `serviceLimit` feature');
26 serviceLimitStore.stop();
27 }
28 },
29 {
30 fireImmediately: true,
31 },
32 );
33}
diff --git a/src/features/serviceLimit/store.js b/src/features/serviceLimit/store.js
new file mode 100644
index 000000000..9836c5f51
--- /dev/null
+++ b/src/features/serviceLimit/store.js
@@ -0,0 +1,41 @@
1import { computed, observable } from 'mobx';
2import { FeatureStore } from '../utils/FeatureStore';
3import { DEFAULT_SERVICE_LIMIT } from '.';
4
5const debug = require('debug')('Franz:feature:serviceLimit:store');
6
7export class ServiceLimitStore extends FeatureStore {
8 @observable isServiceLimitEnabled = false;
9
10 start(stores, actions) {
11 debug('start');
12 this.stores = stores;
13 this.actions = actions;
14
15 this.isServiceLimitEnabled = true;
16 }
17
18 stop() {
19 super.stop();
20
21 this.isServiceLimitEnabled = false;
22 }
23
24 @computed get userHasReachedServiceLimit() {
25 if (!this.isServiceLimitEnabled) return false;
26
27 return this.serviceLimit !== 0 && this.serviceCount >= this.serviceLimit;
28 }
29
30 @computed get serviceLimit() {
31 if (!this.isServiceLimitEnabled || this.stores.features.features.serviceLimitCount === 0) return 0;
32
33 return this.stores.features.features.serviceLimitCount || DEFAULT_SERVICE_LIMIT;
34 }
35
36 @computed get serviceCount() {
37 return this.stores.services.all.length;
38 }
39}
40
41export default ServiceLimitStore;
diff --git a/src/features/serviceProxy/index.js b/src/features/serviceProxy/index.js
index 4bea327ad..55c600de4 100644
--- a/src/features/serviceProxy/index.js
+++ b/src/features/serviceProxy/index.js
@@ -9,17 +9,17 @@ const debug = require('debug')('Franz:feature:serviceProxy');
9 9
10export const config = observable({ 10export const config = observable({
11 isEnabled: DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled, 11 isEnabled: DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled,
12 isPremium: DEFAULT_FEATURES_CONFIG.isServiceProxyPremiumFeature, 12 isPremium: DEFAULT_FEATURES_CONFIG.isServiceProxyIncludedInCurrentPlan,
13}); 13});
14 14
15export default function init(stores) { 15export default function init(stores) {
16 debug('Initializing `serviceProxy` feature'); 16 debug('Initializing `serviceProxy` feature');
17 17
18 autorun(() => { 18 autorun(() => {
19 const { isServiceProxyEnabled, isServiceProxyPremiumFeature } = stores.features.features; 19 const { isServiceProxyEnabled, isServiceProxyIncludedInCurrentPlan } = stores.features.features;
20 20
21 config.isEnabled = isServiceProxyEnabled !== undefined ? isServiceProxyEnabled : DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled; 21 config.isEnabled = isServiceProxyEnabled !== undefined ? isServiceProxyEnabled : DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled;
22 config.isPremium = isServiceProxyPremiumFeature !== undefined ? isServiceProxyPremiumFeature : DEFAULT_FEATURES_CONFIG.isServiceProxyPremiumFeature; 22 config.isIncludedInCurrentPlan = isServiceProxyIncludedInCurrentPlan !== undefined ? isServiceProxyIncludedInCurrentPlan : DEFAULT_FEATURES_CONFIG.isServiceProxyIncludedInCurrentPlan;
23 23
24 const services = stores.services.enabled; 24 const services = stores.services.enabled;
25 const isPremiumUser = stores.user.data.isPremium; 25 const isPremiumUser = stores.user.data.isPremium;
@@ -30,7 +30,7 @@ export default function init(stores) {
30 services.forEach((service) => { 30 services.forEach((service) => {
31 const s = session.fromPartition(`persist:service-${service.id}`); 31 const s = session.fromPartition(`persist:service-${service.id}`);
32 32
33 if (config.isEnabled && (isPremiumUser || !config.isPremium)) { 33 if (config.isEnabled && (isPremiumUser || !config.isIncludedInCurrentPlan)) {
34 const serviceProxyConfig = proxySettings[service.id]; 34 const serviceProxyConfig = proxySettings[service.id];
35 35
36 if (serviceProxyConfig && serviceProxyConfig.isEnabled && serviceProxyConfig.host) { 36 if (serviceProxyConfig && serviceProxyConfig.isEnabled && serviceProxyConfig.host) {
diff --git a/src/features/shareFranz/Component.js b/src/features/shareFranz/Component.js
index 8d1d595c5..859c0ebe9 100644
--- a/src/features/shareFranz/Component.js
+++ b/src/features/shareFranz/Component.js
@@ -6,6 +6,7 @@ import { defineMessages, intlShape } from 'react-intl';
6import { Button } from '@meetfranz/forms'; 6import { Button } from '@meetfranz/forms';
7import { H1, Icon } from '@meetfranz/ui'; 7import { H1, Icon } from '@meetfranz/ui';
8 8
9import { mdiHeart } from '@mdi/js';
9import Modal from '../../components/ui/Modal'; 10import Modal from '../../components/ui/Modal';
10import { state } from '.'; 11import { state } from '.';
11import { gaEvent } from '../../lib/analytics'; 12import { gaEvent } from '../../lib/analytics';
@@ -116,7 +117,7 @@ export default @injectSheet(styles) @inject('stores') @observer class ShareFranz
116 close={this.close.bind(this)} 117 close={this.close.bind(this)}
117 > 118 >
118 <div className={classes.heartContainer}> 119 <div className={classes.heartContainer}>
119 <Icon icon="mdiHeart" className={classes.heart} size={4} /> 120 <Icon icon={mdiHeart} className={classes.heart} size={4} />
120 </div> 121 </div>
121 <H1 className={classes.headline}> 122 <H1 className={classes.headline}>
122 {intl.formatMessage(messages.headline)} 123 {intl.formatMessage(messages.headline)}
diff --git a/src/features/spellchecker/index.js b/src/features/spellchecker/index.js
index 79a2172b4..a07f9f63a 100644
--- a/src/features/spellchecker/index.js
+++ b/src/features/spellchecker/index.js
@@ -5,18 +5,18 @@ import { DEFAULT_FEATURES_CONFIG } from '../../config';
5const debug = require('debug')('Franz:feature:spellchecker'); 5const debug = require('debug')('Franz:feature:spellchecker');
6 6
7export const config = observable({ 7export const config = observable({
8 isPremium: DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature, 8 isIncludedInCurrentPlan: DEFAULT_FEATURES_CONFIG.isSpellcheckerIncludedInCurrentPlan,
9}); 9});
10 10
11export default function init(stores) { 11export default function init(stores) {
12 debug('Initializing `spellchecker` feature'); 12 debug('Initializing `spellchecker` feature');
13 13
14 autorun(() => { 14 autorun(() => {
15 const { isSpellcheckerPremiumFeature } = stores.features.features; 15 const { isSpellcheckerIncludedInCurrentPlan } = stores.features.features;
16 16
17 config.isPremium = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; 17 config.isIncludedInCurrentPlan = isSpellcheckerIncludedInCurrentPlan !== undefined ? isSpellcheckerIncludedInCurrentPlan : DEFAULT_FEATURES_CONFIG.isSpellcheckerIncludedInCurrentPlan;
18 18
19 if (!stores.user.data.isPremium && config.isPremium && stores.settings.app.enableSpellchecking) { 19 if (!stores.user.data.isPremium && config.isIncludedInCurrentPlan && stores.settings.app.enableSpellchecking) {
20 debug('Override settings.spellcheckerEnabled flag to false'); 20 debug('Override settings.spellcheckerEnabled flag to false');
21 21
22 Object.assign(stores.settings.app, { 22 Object.assign(stores.settings.app, {
diff --git a/src/features/todos/actions.js b/src/features/todos/actions.js
new file mode 100644
index 000000000..dc63d5fcd
--- /dev/null
+++ b/src/features/todos/actions.js
@@ -0,0 +1,22 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const todoActions = createActionsFromDefinitions({
5 resize: {
6 width: PropTypes.number.isRequired,
7 },
8 toggleTodosPanel: {},
9 setTodosWebview: {
10 webview: PropTypes.instanceOf(Element).isRequired,
11 },
12 handleHostMessage: {
13 action: PropTypes.string.isRequired,
14 data: PropTypes.object,
15 },
16 handleClientMessage: {
17 action: PropTypes.string.isRequired,
18 data: PropTypes.object,
19 },
20}, PropTypes.checkPropTypes);
21
22export default todoActions;
diff --git a/src/features/todos/components/TodosWebview.js b/src/features/todos/components/TodosWebview.js
new file mode 100644
index 000000000..288c1906f
--- /dev/null
+++ b/src/features/todos/components/TodosWebview.js
@@ -0,0 +1,237 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5import Webview from 'react-electron-web-view';
6import { Icon } from '@meetfranz/ui';
7
8import * as environment from '../../../environment';
9
10const OPEN_TODOS_BUTTON_SIZE = 45;
11const CLOSE_TODOS_BUTTON_SIZE = 35;
12
13const styles = theme => ({
14 root: {
15 background: theme.colorBackground,
16 position: 'relative',
17 borderLeft: [1, 'solid', theme.todos.todosLayer.borderLeftColor],
18 zIndex: 300,
19
20 transform: ({ isVisible, width }) => `translateX(${isVisible ? 0 : width}px)`,
21
22 '&:hover $closeTodosButton': {
23 opacity: 1,
24 },
25 },
26 webview: {
27 height: '100%',
28
29 '& webview': {
30 height: '100%',
31 },
32 },
33 resizeHandler: {
34 position: 'absolute',
35 left: 0,
36 marginLeft: -5,
37 width: 10,
38 zIndex: 400,
39 cursor: 'col-resize',
40 },
41 dragIndicator: {
42 position: 'absolute',
43 left: 0,
44 width: 5,
45 zIndex: 400,
46 background: theme.todos.dragIndicator.background,
47
48 },
49 openTodosButton: {
50 width: OPEN_TODOS_BUTTON_SIZE,
51 height: OPEN_TODOS_BUTTON_SIZE,
52 background: theme.todos.toggleButton.background,
53 position: 'absolute',
54 bottom: 80,
55 right: props => (props.width + (props.isVisible ? -OPEN_TODOS_BUTTON_SIZE / 2 : 0)),
56 borderRadius: OPEN_TODOS_BUTTON_SIZE / 2,
57 opacity: props => (props.isVisible ? 0 : 1),
58 transition: 'right 0.5s',
59 zIndex: 600,
60 display: 'flex',
61 alignItems: 'center',
62 justifyContent: 'center',
63 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
64
65 borderTopRightRadius: props => (props.isVisible ? null : 0),
66 borderBottomRightRadius: props => (props.isVisible ? null : 0),
67
68 '& svg': {
69 fill: theme.todos.toggleButton.textColor,
70 transition: 'all 0.5s',
71 },
72 },
73 closeTodosButton: {
74 width: CLOSE_TODOS_BUTTON_SIZE,
75 height: CLOSE_TODOS_BUTTON_SIZE,
76 background: theme.todos.toggleButton.background,
77 position: 'absolute',
78 bottom: 80,
79 right: ({ width }) => (width + -CLOSE_TODOS_BUTTON_SIZE / 2),
80 borderRadius: CLOSE_TODOS_BUTTON_SIZE / 2,
81 opacity: 0,
82 transition: 'opacity 0.5s',
83 zIndex: 600,
84 display: 'flex',
85 alignItems: 'center',
86 justifyContent: 'center',
87 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
88
89 '& svg': {
90 fill: theme.todos.toggleButton.textColor,
91 },
92 },
93});
94
95@injectSheet(styles) @observer
96class TodosWebview extends Component {
97 static propTypes = {
98 classes: PropTypes.object.isRequired,
99 isVisible: PropTypes.bool.isRequired,
100 togglePanel: PropTypes.func.isRequired,
101 handleClientMessage: PropTypes.func.isRequired,
102 setTodosWebview: PropTypes.func.isRequired,
103 resize: PropTypes.func.isRequired,
104 width: PropTypes.number.isRequired,
105 minWidth: PropTypes.number.isRequired,
106 };
107
108 state = {
109 isDragging: false,
110 width: 300,
111 };
112
113 componentWillMount() {
114 const { width } = this.props;
115
116 this.setState({
117 width,
118 });
119 }
120
121 componentDidMount() {
122 this.node.addEventListener('mousemove', this.resizePanel.bind(this));
123 this.node.addEventListener('mouseup', this.stopResize.bind(this));
124 this.node.addEventListener('mouseleave', this.stopResize.bind(this));
125 }
126
127 startResize = (event) => {
128 this.setState({
129 isDragging: true,
130 initialPos: event.clientX,
131 delta: 0,
132 });
133 };
134
135 resizePanel(e) {
136 const { minWidth } = this.props;
137
138 const {
139 isDragging,
140 initialPos,
141 } = this.state;
142
143 if (isDragging && Math.abs(e.clientX - window.innerWidth) > minWidth) {
144 const delta = e.clientX - initialPos;
145
146 this.setState({
147 delta,
148 });
149 }
150 }
151
152 stopResize() {
153 const {
154 resize,
155 minWidth,
156 } = this.props;
157
158 const {
159 isDragging,
160 delta,
161 width,
162 } = this.state;
163
164 if (isDragging) {
165 let newWidth = width + (delta < 0 ? Math.abs(delta) : -Math.abs(delta));
166
167 if (newWidth < minWidth) {
168 newWidth = minWidth;
169 }
170
171 this.setState({
172 isDragging: false,
173 delta: 0,
174 width: newWidth,
175 });
176
177 resize(newWidth);
178 }
179 }
180
181 startListeningToIpcMessages() {
182 const { handleClientMessage } = this.props;
183 if (!this.webview) return;
184 this.webview.addEventListener('ipc-message', e => handleClientMessage(e.args[0]));
185 }
186
187 render() {
188 const {
189 classes, isVisible, togglePanel,
190 } = this.props;
191 const { width, delta, isDragging } = this.state;
192
193 return (
194 <>
195 <div
196 className={classes.root}
197 style={{ width: isVisible ? width : 0 }}
198 onMouseUp={() => this.stopResize()}
199 ref={(node) => { this.node = node; }}
200 >
201 <button
202 onClick={() => togglePanel()}
203 className={isVisible ? classes.closeTodosButton : classes.openTodosButton}
204 type="button"
205 >
206 <Icon icon={isVisible ? 'mdiChevronRight' : 'mdiCheckAll'} size={2} />
207 </button>
208 <div
209 className={classes.resizeHandler}
210 style={Object.assign({ left: delta }, isDragging ? { width: 600, marginLeft: -200 } : {})} // This hack is required as resizing with webviews beneath behaves quite bad
211 onMouseDown={e => this.startResize(e)}
212 />
213 {isDragging && (
214 <div
215 className={classes.dragIndicator}
216 style={{ left: delta }} // This hack is required as resizing with webviews beneath behaves quite bad
217 />
218 )}
219 <Webview
220 className={classes.webview}
221 onDidAttach={() => {
222 const { setTodosWebview } = this.props;
223 setTodosWebview(this.webview);
224 this.startListeningToIpcMessages();
225 }}
226 partition="persist:todos"
227 preload="./features/todos/preload.js"
228 ref={(webview) => { this.webview = webview ? webview.view : null; }}
229 src={environment.TODOS_FRONTEND}
230 />
231 </div>
232 </>
233 );
234 }
235}
236
237export default TodosWebview;
diff --git a/src/features/todos/constants.js b/src/features/todos/constants.js
new file mode 100644
index 000000000..2e8a431cc
--- /dev/null
+++ b/src/features/todos/constants.js
@@ -0,0 +1,4 @@
1export const IPC = {
2 TODOS_HOST_CHANNEL: 'TODOS_HOST_CHANNEL',
3 TODOS_CLIENT_CHANNEL: 'TODOS_CLIENT_CHANNEL',
4};
diff --git a/src/features/todos/containers/TodosScreen.js b/src/features/todos/containers/TodosScreen.js
new file mode 100644
index 000000000..d071d0677
--- /dev/null
+++ b/src/features/todos/containers/TodosScreen.js
@@ -0,0 +1,32 @@
1import React, { Component } from 'react';
2import { observer } from 'mobx-react';
3
4import TodosWebview from '../components/TodosWebview';
5import ErrorBoundary from '../../../components/util/ErrorBoundary';
6import { TODOS_MIN_WIDTH, todosStore } from '..';
7import { todoActions } from '../actions';
8
9@observer
10class TodosScreen extends Component {
11 render() {
12 if (!todosStore || !todosStore.isFeatureActive) {
13 return null;
14 }
15
16 return (
17 <ErrorBoundary>
18 <TodosWebview
19 isVisible={todosStore.isTodosPanelVisible}
20 togglePanel={todoActions.toggleTodosPanel}
21 handleClientMessage={todoActions.handleClientMessage}
22 setTodosWebview={webview => todoActions.setTodosWebview({ webview })}
23 width={todosStore.width}
24 minWidth={TODOS_MIN_WIDTH}
25 resize={width => todoActions.resize({ width })}
26 />
27 </ErrorBoundary>
28 );
29 }
30}
31
32export default TodosScreen;
diff --git a/src/features/todos/index.js b/src/features/todos/index.js
new file mode 100644
index 000000000..00b165cc5
--- /dev/null
+++ b/src/features/todos/index.js
@@ -0,0 +1,34 @@
1import { reaction } from 'mobx';
2import TodoStore from './store';
3
4const debug = require('debug')('Franz:feature:todos');
5
6export const GA_CATEGORY_TODOS = 'Todos';
7
8export const DEFAULT_TODOS_WIDTH = 300;
9export const TODOS_MIN_WIDTH = 200;
10export const DEFAULT_TODOS_VISIBLE = true;
11
12export const todosStore = new TodoStore();
13
14export default function initTodos(stores, actions) {
15 stores.todos = todosStore;
16 const { features } = stores;
17
18 // Toggle todos feature
19 reaction(
20 () => features.features.isTodosEnabled,
21 (isEnabled) => {
22 if (isEnabled) {
23 debug('Initializing `todos` feature');
24 todosStore.start(stores, actions);
25 } else if (todosStore.isFeatureActive) {
26 debug('Disabling `todos` feature');
27 todosStore.stop();
28 }
29 },
30 {
31 fireImmediately: true,
32 },
33 );
34}
diff --git a/src/features/todos/preload.js b/src/features/todos/preload.js
new file mode 100644
index 000000000..6e38a2ef3
--- /dev/null
+++ b/src/features/todos/preload.js
@@ -0,0 +1,23 @@
1import { ipcRenderer } from 'electron';
2import { IPC } from './constants';
3
4const debug = require('debug')('Franz:feature:todos:preload');
5
6debug('Preloading Todos Webview');
7
8let hostMessageListener = () => {};
9
10window.franz = {
11 onInitialize(ipcHostMessageListener) {
12 hostMessageListener = ipcHostMessageListener;
13 ipcRenderer.sendToHost(IPC.TODOS_CLIENT_CHANNEL, { action: 'todos:initialized' });
14 },
15 sendToHost(message) {
16 ipcRenderer.sendToHost(IPC.TODOS_CLIENT_CHANNEL, message);
17 },
18};
19
20ipcRenderer.on(IPC.TODOS_HOST_CHANNEL, (event, message) => {
21 debug('Received host message', event, message);
22 hostMessageListener(message);
23});
diff --git a/src/features/todos/store.js b/src/features/todos/store.js
new file mode 100644
index 000000000..acf95df0d
--- /dev/null
+++ b/src/features/todos/store.js
@@ -0,0 +1,147 @@
1import { ThemeType } from '@meetfranz/theme';
2import {
3 computed,
4 action,
5 observable,
6} from 'mobx';
7import localStorage from 'mobx-localstorage';
8
9import { todoActions } from './actions';
10import { FeatureStore } from '../utils/FeatureStore';
11import { createReactions } from '../../stores/lib/Reaction';
12import { createActionBindings } from '../utils/ActionBinding';
13import { DEFAULT_TODOS_WIDTH, TODOS_MIN_WIDTH, DEFAULT_TODOS_VISIBLE } from '.';
14import { IPC } from './constants';
15
16const debug = require('debug')('Franz:feature:todos:store');
17
18export default class TodoStore extends FeatureStore {
19 @observable isFeatureEnabled = false;
20
21 @observable isFeatureActive = false;
22
23 webview = null;
24
25 @computed get width() {
26 const width = this.settings.width || DEFAULT_TODOS_WIDTH;
27
28 return width < TODOS_MIN_WIDTH ? TODOS_MIN_WIDTH : width;
29 }
30
31 @computed get isTodosPanelVisible() {
32 if (this.settings.isTodosPanelVisible === undefined) return DEFAULT_TODOS_VISIBLE;
33
34 return this.settings.isTodosPanelVisible;
35 }
36
37 @computed get settings() {
38 return localStorage.getItem('todos') || {};
39 }
40
41 // ========== PUBLIC API ========= //
42
43 @action start(stores, actions) {
44 debug('TodoStore::start');
45 this.stores = stores;
46 this.actions = actions;
47
48 // ACTIONS
49
50 this._registerActions(createActionBindings([
51 [todoActions.resize, this._resize],
52 [todoActions.toggleTodosPanel, this._toggleTodosPanel],
53 [todoActions.setTodosWebview, this._setTodosWebview],
54 [todoActions.handleHostMessage, this._handleHostMessage],
55 [todoActions.handleClientMessage, this._handleClientMessage],
56 ]));
57
58 // REACTIONS
59
60 this._allReactions = createReactions([
61 this._setFeatureEnabledReaction,
62 ]);
63
64 this._registerReactions(this._allReactions);
65
66 this.isFeatureActive = true;
67 }
68
69 @action stop() {
70 super.stop();
71 debug('TodoStore::stop');
72 this.reset();
73 this.isFeatureActive = false;
74 }
75
76 // ========== PRIVATE METHODS ========= //
77
78 _updateSettings = (changes) => {
79 localStorage.setItem('todos', {
80 ...this.settings,
81 ...changes,
82 });
83 };
84
85 // Actions
86
87 @action _resize = ({ width }) => {
88 this._updateSettings({
89 width,
90 });
91 };
92
93 @action _toggleTodosPanel = () => {
94 this._updateSettings({
95 isTodosPanelVisible: !this.isTodosPanelVisible,
96 });
97 };
98
99 @action _setTodosWebview = ({ webview }) => {
100 debug('_setTodosWebview', webview);
101 this.webview = webview;
102 };
103
104 @action _handleHostMessage = (message) => {
105 debug('_handleHostMessage', message);
106 if (message.action === 'todos:create') {
107 this.webview.send(IPC.TODOS_HOST_CHANNEL, message);
108 }
109 };
110
111 @action _handleClientMessage = (message) => {
112 debug('_handleClientMessage', message);
113 switch (message.action) {
114 case 'todos:initialized': this._onTodosClientInitialized(); break;
115 case 'todos:goToService': this._goToService(message.data); break;
116 default:
117 debug('Unknown client message reiceived', message);
118 }
119 };
120
121 // Todos client message handlers
122
123 _onTodosClientInitialized = () => {
124 this.webview.send(IPC.TODOS_HOST_CHANNEL, {
125 action: 'todos:configure',
126 data: {
127 authToken: this.stores.user.authToken,
128 theme: this.stores.ui.isDarkThemeActive ? ThemeType.dark : ThemeType.default,
129 },
130 });
131 };
132
133 _goToService = ({ url, serviceId }) => {
134 if (url) {
135 this.stores.services.one(serviceId).webview.loadURL(url);
136 }
137 this.actions.service.setActive({ serviceId });
138 };
139
140 // Reactions
141
142 _setFeatureEnabledReaction = () => {
143 const { isTodosEnabled } = this.stores.features.features;
144
145 this.isFeatureEnabled = isTodosEnabled;
146 };
147}
diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js
index 07b16ff23..e44569be9 100644
--- a/src/features/workspaces/store.js
+++ b/src/features/workspaces/store.js
@@ -79,7 +79,7 @@ export default class WorkspacesStore extends FeatureStore {
79 79
80 // ========== PUBLIC API ========= // 80 // ========== PUBLIC API ========= //
81 81
82 start(stores, actions) { 82 @action start(stores, actions) {
83 debug('WorkspacesStore::start'); 83 debug('WorkspacesStore::start');
84 this.stores = stores; 84 this.stores = stores;
85 this.actions = actions; 85 this.actions = actions;
@@ -104,7 +104,7 @@ export default class WorkspacesStore extends FeatureStore {
104 // REACTIONS 104 // REACTIONS
105 105
106 this._freeUserReactions = createReactions([ 106 this._freeUserReactions = createReactions([
107 this._stopPremiumActionsAndReactions, 107 this._disablePremiumFeatures,
108 this._openDrawerWithSettingsReaction, 108 this._openDrawerWithSettingsReaction,
109 this._setFeatureEnabledReaction, 109 this._setFeatureEnabledReaction,
110 this._setIsPremiumFeatureReaction, 110 this._setIsPremiumFeatureReaction,
@@ -123,10 +123,7 @@ export default class WorkspacesStore extends FeatureStore {
123 this.isFeatureActive = true; 123 this.isFeatureActive = true;
124 } 124 }
125 125
126 stop() { 126 @action reset() {
127 super.stop();
128 debug('WorkspacesStore::stop');
129 this.isFeatureActive = false;
130 this.activeWorkspace = null; 127 this.activeWorkspace = null;
131 this.nextWorkspace = null; 128 this.nextWorkspace = null;
132 this.workspaceBeingEdited = null; 129 this.workspaceBeingEdited = null;
@@ -134,6 +131,13 @@ export default class WorkspacesStore extends FeatureStore {
134 this.isWorkspaceDrawerOpen = false; 131 this.isWorkspaceDrawerOpen = false;
135 } 132 }
136 133
134 @action stop() {
135 super.stop();
136 debug('WorkspacesStore::stop');
137 this.reset();
138 this.isFeatureActive = false;
139 }
140
137 filterServicesByActiveWorkspace = (services) => { 141 filterServicesByActiveWorkspace = (services) => {
138 const { activeWorkspace, isFeatureActive } = this; 142 const { activeWorkspace, isFeatureActive } = this;
139 if (isFeatureActive && activeWorkspace) { 143 if (isFeatureActive && activeWorkspace) {
@@ -251,9 +255,9 @@ export default class WorkspacesStore extends FeatureStore {
251 _setIsPremiumFeatureReaction = () => { 255 _setIsPremiumFeatureReaction = () => {
252 const { features, user } = this.stores; 256 const { features, user } = this.stores;
253 const { isPremium } = user.data; 257 const { isPremium } = user.data;
254 const { isWorkspacePremiumFeature } = features.features; 258 const { isWorkspaceIncludedInCurrentPlan } = features.features;
255 this.isPremiumFeature = isWorkspacePremiumFeature; 259 this.isPremiumFeature = !isWorkspaceIncludedInCurrentPlan;
256 this.isPremiumUpgradeRequired = isWorkspacePremiumFeature && !isPremium; 260 this.isPremiumUpgradeRequired = !isWorkspaceIncludedInCurrentPlan && !isPremium;
257 }; 261 };
258 262
259 _setWorkspaceBeingEditedReaction = () => { 263 _setWorkspaceBeingEditedReaction = () => {
@@ -281,6 +285,7 @@ export default class WorkspacesStore extends FeatureStore {
281 }; 285 };
282 286
283 _activateLastUsedWorkspaceReaction = () => { 287 _activateLastUsedWorkspaceReaction = () => {
288 debug('_activateLastUsedWorkspaceReaction');
284 if (!this.activeWorkspace && this.userHasWorkspaces) { 289 if (!this.activeWorkspace && this.userHasWorkspaces) {
285 const { lastActiveWorkspace } = this.settings; 290 const { lastActiveWorkspace } = this.settings;
286 if (lastActiveWorkspace) { 291 if (lastActiveWorkspace) {
@@ -324,10 +329,12 @@ export default class WorkspacesStore extends FeatureStore {
324 }); 329 });
325 }; 330 };
326 331
327 _stopPremiumActionsAndReactions = () => { 332 _disablePremiumFeatures = () => {
328 if (!this.isUserAllowedToUseFeature) { 333 if (!this.isUserAllowedToUseFeature) {
334 debug('_disablePremiumFeatures');
329 this._stopActions(this._premiumUserActions); 335 this._stopActions(this._premiumUserActions);
330 this._stopReactions(this._premiumUserReactions); 336 this._stopReactions(this._premiumUserReactions);
337 this.reset();
331 } else { 338 } else {
332 this._startActions(this._premiumUserActions); 339 this._startActions(this._premiumUserActions);
333 this._startReactions(this._premiumUserReactions); 340 this._startReactions(this._premiumUserReactions);