aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/todos
diff options
context:
space:
mode:
authorLibravatar vantezzen <properly@protonmail.com>2019-09-07 15:50:23 +0200
committerLibravatar vantezzen <properly@protonmail.com>2019-09-07 15:50:23 +0200
commite7a74514c1e7c3833dfdcf5900cb87f9e6e8354e (patch)
treeb8314e4155503b135dcb07e8b4a0e847e25c19cf /src/features/todos
parentUpdate CHANGELOG.md (diff)
parentUpdate CHANGELOG.md (diff)
downloadferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.tar.gz
ferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.tar.zst
ferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.zip
Merge branch 'master' of https://github.com/meetfranz/franz into franz-5.3.0
Diffstat (limited to 'src/features/todos')
-rw-r--r--src/features/todos/actions.js23
-rw-r--r--src/features/todos/components/TodosWebview.js300
-rw-r--r--src/features/todos/constants.js4
-rw-r--r--src/features/todos/containers/TodosScreen.js41
-rw-r--r--src/features/todos/index.js39
-rw-r--r--src/features/todos/preload.js23
-rw-r--r--src/features/todos/store.js213
7 files changed, 643 insertions, 0 deletions
diff --git a/src/features/todos/actions.js b/src/features/todos/actions.js
new file mode 100644
index 000000000..1ccc9a592
--- /dev/null
+++ b/src/features/todos/actions.js
@@ -0,0 +1,23 @@
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 toggleTodosFeatureVisibility: {},
10 setTodosWebview: {
11 webview: PropTypes.instanceOf(Element).isRequired,
12 },
13 handleHostMessage: {
14 action: PropTypes.string.isRequired,
15 data: PropTypes.object,
16 },
17 handleClientMessage: {
18 action: PropTypes.string.isRequired,
19 data: PropTypes.object,
20 },
21}, PropTypes.checkPropTypes);
22
23export default todoActions;
diff --git a/src/features/todos/components/TodosWebview.js b/src/features/todos/components/TodosWebview.js
new file mode 100644
index 000000000..c06183e37
--- /dev/null
+++ b/src/features/todos/components/TodosWebview.js
@@ -0,0 +1,300 @@
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';
7import { defineMessages, intlShape } from 'react-intl';
8
9import { mdiChevronRight, mdiCheckAll } from '@mdi/js';
10import * as environment from '../../../environment';
11import Appear from '../../../components/ui/effects/Appear';
12import UpgradeButton from '../../../components/ui/UpgradeButton';
13
14const OPEN_TODOS_BUTTON_SIZE = 45;
15const CLOSE_TODOS_BUTTON_SIZE = 35;
16
17const messages = defineMessages({
18 premiumInfo: {
19 id: 'feature.todos.premium.info',
20 defaultMessage: '!!!Franz Todos are available to premium users now!',
21 },
22 upgradeCTA: {
23 id: 'feature.todos.premium.upgrade',
24 defaultMessage: '!!!Upgrade Account',
25 },
26 rolloutInfo: {
27 id: 'feature.todos.premium.rollout',
28 defaultMessage: '!!!Everyone else will have to wait a little longer.',
29 },
30});
31
32const styles = theme => ({
33 root: {
34 background: theme.colorBackground,
35 position: 'relative',
36 borderLeft: [1, 'solid', theme.todos.todosLayer.borderLeftColor],
37 zIndex: 300,
38
39 transform: ({ isVisible, width }) => `translateX(${isVisible ? 0 : width}px)`,
40
41 '&:hover $closeTodosButton': {
42 opacity: 1,
43 },
44 '& webview': {
45 height: '100%',
46 },
47 },
48 resizeHandler: {
49 position: 'absolute',
50 left: 0,
51 marginLeft: -5,
52 width: 10,
53 zIndex: 400,
54 cursor: 'col-resize',
55 },
56 dragIndicator: {
57 position: 'absolute',
58 left: 0,
59 width: 5,
60 zIndex: 400,
61 background: theme.todos.dragIndicator.background,
62
63 },
64 openTodosButton: {
65 width: OPEN_TODOS_BUTTON_SIZE,
66 height: OPEN_TODOS_BUTTON_SIZE,
67 background: theme.todos.toggleButton.background,
68 position: 'absolute',
69 bottom: 120,
70 right: props => (props.width + (props.isVisible ? -OPEN_TODOS_BUTTON_SIZE / 2 : 0)),
71 borderRadius: OPEN_TODOS_BUTTON_SIZE / 2,
72 opacity: props => (props.isVisible ? 0 : 1),
73 transition: 'right 0.5s',
74 zIndex: 600,
75 display: 'flex',
76 alignItems: 'center',
77 justifyContent: 'center',
78 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
79
80 borderTopRightRadius: props => (props.isVisible ? null : 0),
81 borderBottomRightRadius: props => (props.isVisible ? null : 0),
82
83 '& svg': {
84 fill: theme.todos.toggleButton.textColor,
85 transition: 'all 0.5s',
86 },
87 },
88 closeTodosButton: {
89 width: CLOSE_TODOS_BUTTON_SIZE,
90 height: CLOSE_TODOS_BUTTON_SIZE,
91 background: theme.todos.toggleButton.background,
92 position: 'absolute',
93 bottom: 120,
94 right: ({ width }) => (width + -CLOSE_TODOS_BUTTON_SIZE / 2),
95 borderRadius: CLOSE_TODOS_BUTTON_SIZE / 2,
96 opacity: ({ isTodosIncludedInCurrentPlan }) => (!isTodosIncludedInCurrentPlan ? 1 : 0),
97 transition: 'opacity 0.5s',
98 zIndex: 600,
99 display: 'flex',
100 alignItems: 'center',
101 justifyContent: 'center',
102 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
103
104 '& svg': {
105 fill: theme.todos.toggleButton.textColor,
106 },
107 },
108 premiumContainer: {
109 display: 'flex',
110 flexDirection: 'column',
111 justifyContent: 'center',
112 alignItems: 'center',
113 width: '80%',
114 maxWidth: 300,
115 margin: [0, 'auto'],
116 textAlign: 'center',
117 },
118 premiumIcon: {
119 marginBottom: 40,
120 background: theme.styleTypes.primary.accent,
121 fill: theme.styleTypes.primary.contrast,
122 padding: 10,
123 borderRadius: 10,
124 },
125 premiumCTA: {
126 marginTop: 40,
127 },
128});
129
130@injectSheet(styles) @observer
131class TodosWebview extends Component {
132 static propTypes = {
133 classes: PropTypes.object.isRequired,
134 isVisible: PropTypes.bool.isRequired,
135 togglePanel: PropTypes.func.isRequired,
136 handleClientMessage: PropTypes.func.isRequired,
137 setTodosWebview: PropTypes.func.isRequired,
138 resize: PropTypes.func.isRequired,
139 width: PropTypes.number.isRequired,
140 minWidth: PropTypes.number.isRequired,
141 isTodosIncludedInCurrentPlan: PropTypes.bool.isRequired,
142 };
143
144 state = {
145 isDragging: false,
146 width: 300,
147 };
148
149 static contextTypes = {
150 intl: intlShape,
151 };
152
153 componentWillMount() {
154 const { width } = this.props;
155
156 this.setState({
157 width,
158 });
159 }
160
161 componentDidMount() {
162 this.node.addEventListener('mousemove', this.resizePanel.bind(this));
163 this.node.addEventListener('mouseup', this.stopResize.bind(this));
164 this.node.addEventListener('mouseleave', this.stopResize.bind(this));
165 }
166
167 startResize = (event) => {
168 this.setState({
169 isDragging: true,
170 initialPos: event.clientX,
171 delta: 0,
172 });
173 };
174
175 resizePanel(e) {
176 const { minWidth } = this.props;
177
178 const {
179 isDragging,
180 initialPos,
181 } = this.state;
182
183 if (isDragging && Math.abs(e.clientX - window.innerWidth) > minWidth) {
184 const delta = e.clientX - initialPos;
185
186 this.setState({
187 delta,
188 });
189 }
190 }
191
192 stopResize() {
193 const {
194 resize,
195 minWidth,
196 } = this.props;
197
198 const {
199 isDragging,
200 delta,
201 width,
202 } = this.state;
203
204 if (isDragging) {
205 let newWidth = width + (delta < 0 ? Math.abs(delta) : -Math.abs(delta));
206
207 if (newWidth < minWidth) {
208 newWidth = minWidth;
209 }
210
211 this.setState({
212 isDragging: false,
213 delta: 0,
214 width: newWidth,
215 });
216
217 resize(newWidth);
218 }
219 }
220
221 startListeningToIpcMessages() {
222 const { handleClientMessage } = this.props;
223 if (!this.webview) return;
224 this.webview.addEventListener('ipc-message', e => handleClientMessage(e.args[0]));
225 }
226
227 render() {
228 const {
229 classes,
230 isVisible,
231 togglePanel,
232 isTodosIncludedInCurrentPlan,
233 } = this.props;
234
235 const {
236 width,
237 delta,
238 isDragging,
239 } = this.state;
240
241 const { intl } = this.context;
242
243 return (
244 <div
245 className={classes.root}
246 style={{ width: isVisible ? width : 0 }}
247 onMouseUp={() => this.stopResize()}
248 ref={(node) => { this.node = node; }}
249 >
250 <button
251 onClick={() => togglePanel()}
252 className={isVisible ? classes.closeTodosButton : classes.openTodosButton}
253 type="button"
254 >
255 <Icon icon={isVisible ? mdiChevronRight : mdiCheckAll} size={2} />
256 </button>
257 <div
258 className={classes.resizeHandler}
259 style={Object.assign({ left: delta }, isDragging ? { width: 600, marginLeft: -200 } : {})} // This hack is required as resizing with webviews beneath behaves quite bad
260 onMouseDown={e => this.startResize(e)}
261 />
262 {isDragging && (
263 <div
264 className={classes.dragIndicator}
265 style={{ left: delta }} // This hack is required as resizing with webviews beneath behaves quite bad
266 />
267 )}
268 {isTodosIncludedInCurrentPlan ? (
269 <Webview
270 className={classes.webview}
271 onDidAttach={() => {
272 const { setTodosWebview } = this.props;
273 setTodosWebview(this.webview);
274 this.startListeningToIpcMessages();
275 }}
276 partition="persist:todos"
277 preload="./features/todos/preload.js"
278 ref={(webview) => { this.webview = webview ? webview.view : null; }}
279 src={environment.TODOS_FRONTEND}
280 />
281 ) : (
282 <Appear>
283 <div className={classes.premiumContainer}>
284 <Icon icon={mdiCheckAll} className={classes.premiumIcon} size={4} />
285 <p>{intl.formatMessage(messages.premiumInfo)}</p>
286 <p>{intl.formatMessage(messages.rolloutInfo)}</p>
287 <UpgradeButton
288 className={classes.premiumCTA}
289 gaEventInfo={{ category: 'Todos', event: 'upgrade' }}
290 short
291 />
292 </div>
293 </Appear>
294 )}
295 </div>
296 );
297 }
298}
299
300export 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..a5da0b014
--- /dev/null
+++ b/src/features/todos/containers/TodosScreen.js
@@ -0,0 +1,41 @@
1import React, { Component } from 'react';
2import { observer, inject } from 'mobx-react';
3import PropTypes from 'prop-types';
4
5import FeaturesStore from '../../../stores/FeaturesStore';
6import TodosWebview from '../components/TodosWebview';
7import ErrorBoundary from '../../../components/util/ErrorBoundary';
8import { TODOS_MIN_WIDTH, todosStore } from '..';
9import { todoActions } from '../actions';
10
11@inject('stores', 'actions') @observer
12class TodosScreen extends Component {
13 render() {
14 if (!todosStore || !todosStore.isFeatureActive || todosStore.isTodosPanelForceHidden) {
15 return null;
16 }
17
18 return (
19 <ErrorBoundary>
20 <TodosWebview
21 isVisible={todosStore.isTodosPanelVisible}
22 togglePanel={todoActions.toggleTodosPanel}
23 handleClientMessage={todoActions.handleClientMessage}
24 setTodosWebview={webview => todoActions.setTodosWebview({ webview })}
25 width={todosStore.width}
26 minWidth={TODOS_MIN_WIDTH}
27 resize={width => todoActions.resize({ width })}
28 isTodosIncludedInCurrentPlan={this.props.stores.features.features.isTodosIncludedInCurrentPlan || false}
29 />
30 </ErrorBoundary>
31 );
32 }
33}
34
35export default TodosScreen;
36
37TodosScreen.wrappedComponent.propTypes = {
38 stores: PropTypes.shape({
39 features: PropTypes.instanceOf(FeaturesStore).isRequired,
40 }).isRequired,
41};
diff --git a/src/features/todos/index.js b/src/features/todos/index.js
new file mode 100644
index 000000000..7388aebaf
--- /dev/null
+++ b/src/features/todos/index.js
@@ -0,0 +1,39 @@
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;
11export const DEFAULT_IS_FEATURE_ENABLED_BY_USER = true;
12
13export const TODOS_ROUTES = {
14 TARGET: '/todos',
15};
16
17export const todosStore = new TodoStore();
18
19export default function initTodos(stores, actions) {
20 stores.todos = todosStore;
21 const { features } = stores;
22
23 // Toggle todos feature
24 reaction(
25 () => features.features.isTodosEnabled,
26 (isEnabled) => {
27 if (isEnabled) {
28 debug('Initializing `todos` feature');
29 todosStore.start(stores, actions);
30 } else if (todosStore.isFeatureActive) {
31 debug('Disabling `todos` feature');
32 todosStore.stop();
33 }
34 },
35 {
36 fireImmediately: true,
37 },
38 );
39}
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..05eef4ec1
--- /dev/null
+++ b/src/features/todos/store.js
@@ -0,0 +1,213 @@
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 {
14 DEFAULT_TODOS_WIDTH, TODOS_MIN_WIDTH, DEFAULT_TODOS_VISIBLE, TODOS_ROUTES, DEFAULT_IS_FEATURE_ENABLED_BY_USER,
15} from '.';
16import { IPC } from './constants';
17import { state as delayAppState } from '../delayApp';
18
19const debug = require('debug')('Franz:feature:todos:store');
20
21export default class TodoStore extends FeatureStore {
22 @observable isFeatureEnabled = false;
23
24 @observable isFeatureActive = false;
25
26 webview = null;
27
28 @computed get width() {
29 const width = this.settings.width || DEFAULT_TODOS_WIDTH;
30
31 return width < TODOS_MIN_WIDTH ? TODOS_MIN_WIDTH : width;
32 }
33
34 @computed get isTodosPanelForceHidden() {
35 const { isAnnouncementShown } = this.stores.announcements;
36 return delayAppState.isDelayAppScreenVisible || !this.settings.isFeatureEnabledByUser || isAnnouncementShown;
37 }
38
39 @computed get isTodosPanelVisible() {
40 if (this.settings.isTodosPanelVisible === undefined) return DEFAULT_TODOS_VISIBLE;
41 return this.settings.isTodosPanelVisible;
42 }
43
44 @computed get settings() {
45 return localStorage.getItem('todos') || {};
46 }
47
48 // ========== PUBLIC API ========= //
49
50 @action start(stores, actions) {
51 debug('TodoStore::start');
52 this.stores = stores;
53 this.actions = actions;
54
55 // ACTIONS
56
57 this._registerActions(createActionBindings([
58 [todoActions.resize, this._resize],
59 [todoActions.toggleTodosPanel, this._toggleTodosPanel],
60 [todoActions.setTodosWebview, this._setTodosWebview],
61 [todoActions.handleHostMessage, this._handleHostMessage],
62 [todoActions.handleClientMessage, this._handleClientMessage],
63 [todoActions.toggleTodosFeatureVisibility, this._toggleTodosFeatureVisibility],
64 ]));
65
66 // REACTIONS
67
68 this._allReactions = createReactions([
69 this._setFeatureEnabledReaction,
70 this._updateTodosConfig,
71 this._firstLaunchReaction,
72 this._routeCheckReaction,
73 ]);
74
75 this._registerReactions(this._allReactions);
76
77 this.isFeatureActive = true;
78
79 if (this.settings.isFeatureEnabledByUser === undefined) {
80 this._updateSettings({
81 isFeatureEnabledByUser: DEFAULT_IS_FEATURE_ENABLED_BY_USER,
82 });
83 }
84 }
85
86 @action stop() {
87 super.stop();
88 debug('TodoStore::stop');
89 this.reset();
90 this.isFeatureActive = false;
91 }
92
93 // ========== PRIVATE METHODS ========= //
94
95 _updateSettings = (changes) => {
96 localStorage.setItem('todos', {
97 ...this.settings,
98 ...changes,
99 });
100 };
101
102 // Actions
103
104 @action _resize = ({ width }) => {
105 this._updateSettings({
106 width,
107 });
108 };
109
110 @action _toggleTodosPanel = () => {
111 this._updateSettings({
112 isTodosPanelVisible: !this.isTodosPanelVisible,
113 });
114 };
115
116 @action _setTodosWebview = ({ webview }) => {
117 debug('_setTodosWebview', webview);
118 this.webview = webview;
119 };
120
121 @action _handleHostMessage = (message) => {
122 debug('_handleHostMessage', message);
123 if (message.action === 'todos:create') {
124 this.webview.send(IPC.TODOS_HOST_CHANNEL, message);
125 }
126 };
127
128 @action _handleClientMessage = (message) => {
129 debug('_handleClientMessage', message);
130 switch (message.action) {
131 case 'todos:initialized': this._onTodosClientInitialized(); break;
132 case 'todos:goToService': this._goToService(message.data); break;
133 default:
134 debug('Unknown client message reiceived', message);
135 }
136 };
137
138 @action _toggleTodosFeatureVisibility = () => {
139 debug('_toggleTodosFeatureVisibility');
140
141 this._updateSettings({
142 isFeatureEnabledByUser: !this.settings.isFeatureEnabledByUser,
143 });
144 };
145
146 // Todos client message handlers
147
148 _onTodosClientInitialized = () => {
149 const { authToken } = this.stores.user;
150 const { isDarkThemeActive } = this.stores.ui;
151 const { locale } = this.stores.app;
152 if (!this.webview) return;
153 this.webview.send(IPC.TODOS_HOST_CHANNEL, {
154 action: 'todos:configure',
155 data: {
156 authToken,
157 locale,
158 theme: isDarkThemeActive ? ThemeType.dark : ThemeType.default,
159 },
160 });
161 };
162
163 _goToService = ({ url, serviceId }) => {
164 if (url) {
165 this.stores.services.one(serviceId).webview.loadURL(url);
166 }
167 this.actions.service.setActive({ serviceId });
168 };
169
170 // Reactions
171
172 _setFeatureEnabledReaction = () => {
173 const { isTodosEnabled } = this.stores.features.features;
174
175 this.isFeatureEnabled = isTodosEnabled;
176 };
177
178 _updateTodosConfig = () => {
179 // Resend the config if any part changes in Franz:
180 this._onTodosClientInitialized();
181 };
182
183 _firstLaunchReaction = () => {
184 const { stats } = this.stores.settings.all;
185
186 // Hide todos layer on first app start but show on second
187 if (stats.appStarts <= 1) {
188 this._updateSettings({
189 isTodosPanelVisible: false,
190 });
191 } else if (stats.appStarts <= 2) {
192 this._updateSettings({
193 isTodosPanelVisible: true,
194 });
195 }
196 };
197
198 _routeCheckReaction = () => {
199 const { pathname } = this.stores.router.location;
200
201 if (pathname === TODOS_ROUTES.TARGET) {
202 debug('Router is on todos route, show todos panel');
203 // todosStore.start(stores, actions);
204 this.stores.router.push('/');
205
206 if (!this.isTodosPanelVisible) {
207 this._updateSettings({
208 isTodosPanelVisible: true,
209 });
210 }
211 }
212 }
213}