aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/announcements
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/announcements')
-rw-r--r--src/features/announcements/actions.js10
-rw-r--r--src/features/announcements/api.js33
-rw-r--r--src/features/announcements/components/AnnouncementScreen.js286
-rw-r--r--src/features/announcements/index.js32
-rw-r--r--src/features/announcements/store.js144
5 files changed, 505 insertions, 0 deletions
diff --git a/src/features/announcements/actions.js b/src/features/announcements/actions.js
new file mode 100644
index 000000000..bab496314
--- /dev/null
+++ b/src/features/announcements/actions.js
@@ -0,0 +1,10 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const announcementActions = createActionsFromDefinitions({
5 show: {
6 targetVersion: PropTypes.string,
7 },
8}, PropTypes.checkPropTypes);
9
10export default announcementActions;
diff --git a/src/features/announcements/api.js b/src/features/announcements/api.js
new file mode 100644
index 000000000..a581bd8de
--- /dev/null
+++ b/src/features/announcements/api.js
@@ -0,0 +1,33 @@
1import { remote } from 'electron';
2import Request from '../../stores/lib/Request';
3import { API, API_VERSION } from '../../environment';
4
5const debug = require('debug')('Franz:feature:announcements:api');
6
7export const announcementsApi = {
8 async getCurrentVersion() {
9 debug('getting current version of electron app');
10 return Promise.resolve(remote.app.getVersion());
11 },
12
13 async getChangelog(version) {
14 debug('fetching release changelog from Github');
15 const url = `https://api.github.com/repos/meetfranz/franz/releases/tags/v${version}`;
16 const request = await window.fetch(url, { method: 'GET' });
17 if (!request.ok) return null;
18 const data = await request.json();
19 return data.body;
20 },
21
22 async getAnnouncement(version) {
23 debug('fetching release announcement from api');
24 const url = `${API}/${API_VERSION}/announcements/${version}`;
25 const response = await window.fetch(url, { method: 'GET' });
26 if (!response.ok) return null;
27 return response.json();
28 },
29};
30
31export const getCurrentVersionRequest = new Request(announcementsApi, 'getCurrentVersion');
32export const getChangelogRequest = new Request(announcementsApi, 'getChangelog');
33export const getAnnouncementRequest = new Request(announcementsApi, 'getAnnouncement');
diff --git a/src/features/announcements/components/AnnouncementScreen.js b/src/features/announcements/components/AnnouncementScreen.js
new file mode 100644
index 000000000..dfce6cdd5
--- /dev/null
+++ b/src/features/announcements/components/AnnouncementScreen.js
@@ -0,0 +1,286 @@
1import React, { Component } from 'react';
2import marked from 'marked';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5import { defineMessages, intlShape } from 'react-intl';
6import injectSheet from 'react-jss';
7import { Button } from '@meetfranz/forms';
8
9import { announcementsStore } from '../index';
10import UIStore from '../../../stores/UIStore';
11import { gaEvent } from '../../../lib/analytics';
12
13const renderer = new marked.Renderer();
14
15renderer.link = (href, title, text) => `<a target="_blank" href="${href}" title="${title}">${text}</a>`;
16
17const markedOptions = { sanitize: true, renderer };
18
19const messages = defineMessages({
20 headline: {
21 id: 'feature.announcements.changelog.headline',
22 defaultMessage: '!!!Changes in Franz {version}',
23 },
24});
25
26const smallScreen = '1000px';
27
28const styles = theme => ({
29 container: {
30 background: theme.colorBackground,
31 position: 'absolute',
32 top: 0,
33 zIndex: 140,
34 width: '100%',
35 height: '100%',
36 overflowY: 'auto',
37 },
38 headline: {
39 color: theme.colorHeadline,
40 margin: [25, 0, 40],
41 // 'max-width': 500,
42 'text-align': 'center',
43 'line-height': '1.3em',
44 },
45 announcement: {
46 height: 'auto',
47
48 [`@media(min-width: ${smallScreen})`]: {
49 display: 'flex',
50 flexDirection: 'column',
51 justifyContent: 'center',
52 height: '100vh',
53 },
54 },
55 main: {
56 display: 'flex',
57 flexDirection: 'column',
58 flexGrow: 1,
59 justifyContent: 'center',
60
61 '& h1': {
62 margin: [40, 0, 15],
63 fontSize: 70,
64 color: theme.styleTypes.primary.accent,
65 textAlign: 'center',
66
67 [`@media(min-width: ${smallScreen})`]: {
68 marginTop: 0,
69 },
70 },
71 '& h2': {
72 fontSize: 30,
73 fontWeight: 300,
74 color: theme.colorText,
75 textAlign: 'center',
76 marginBottom: 60,
77 },
78 },
79 mainBody: {
80 display: 'flex',
81 flexDirection: 'column',
82 alignItems: 'center',
83 width: 'calc(100% - 80px)',
84 height: 'auto',
85 margin: '0 auto',
86 [`@media(min-width: ${smallScreen})`]: {
87 flexDirection: 'row',
88 justifyContent: 'center',
89 },
90 },
91 mainImage: {
92 minWidth: 250,
93 maxWidth: 400,
94 margin: '0 auto',
95 marginBottom: 40,
96 '& img': {
97 width: '100%',
98 },
99 [`@media(min-width: ${smallScreen})`]: {
100 margin: 0,
101 },
102 },
103 mainText: {
104 height: 'auto',
105 maxWidth: 600,
106 textAlign: 'center',
107 '& p': {
108 lineHeight: '1.5em',
109 },
110 [`@media(min-width: ${smallScreen})`]: {
111 textAlign: 'left',
112 },
113 },
114 mainCtaButton: {
115 textAlign: 'center',
116 marginTop: 40,
117 [`@media(min-width: ${smallScreen})`]: {
118 textAlign: 'left',
119 },
120 },
121 spotlight: {
122 height: 'auto',
123 background: theme.announcements.spotlight.background,
124 padding: [40, 0],
125 marginTop: 80,
126 [`@media(min-width: ${smallScreen})`]: {
127 marginTop: 0,
128 justifyContent: 'center',
129 alignItems: 'flex-start',
130 display: 'flex',
131 flexDirection: 'row',
132 },
133 },
134 spotlightTopicContainer: {
135 textAlign: 'center',
136 marginBottom: 20,
137
138 [`@media(min-width: ${smallScreen})`]: {
139 marginBottom: 0,
140 minWidth: 250,
141 maxWidth: 330,
142 width: '100%',
143 textAlign: 'right',
144 marginRight: 60,
145 },
146 },
147 spotlightContentContainer: {
148 textAlign: 'center',
149 [`@media(min-width: ${smallScreen})`]: {
150 height: 'auto',
151 maxWidth: 600,
152 paddingRight: 40,
153 textAlign: 'left',
154 },
155 '& p': {
156 lineHeight: '1.5em',
157 },
158 },
159 spotlightTopic: {
160 fontSize: 20,
161 marginBottom: 5,
162 letterSpacing: 0,
163 fontWeight: 100,
164 },
165 spotlightSubject: {
166 fontSize: 20,
167 },
168 changelog: {
169 padding: [0, 60],
170 maxWidth: 700,
171 margin: [100, 'auto'],
172 height: 'auto',
173
174 '& h3': {
175 fontSize: '24px',
176 margin: '1.5em 0 1em 0',
177 },
178 '& li': {
179 marginBottom: '1em',
180 lineHeight: '1.4em',
181 },
182 '& div': {
183 height: 'auto',
184 },
185 },
186});
187
188
189@inject('stores', 'actions') @injectSheet(styles) @observer
190class AnnouncementScreen extends Component {
191 static propTypes = {
192 classes: PropTypes.object.isRequired,
193 stores: PropTypes.shape({
194 ui: PropTypes.instanceOf(UIStore).isRequired,
195 }).isRequired,
196 };
197
198 static contextTypes = {
199 intl: intlShape,
200 };
201
202 render() {
203 const { classes, stores } = this.props;
204 const { intl } = this.context;
205 const { changelog, announcement } = announcementsStore;
206 const themeImage = stores.ui.isDarkThemeActive ? 'dark' : 'light';
207 return (
208 <div className={classes.container}>
209 {announcement && (
210 <div className={classes.announcement}>
211 <div className={classes.main}>
212 <h1>{announcement.main.headline}</h1>
213 <h2>{announcement.main.subHeadline}</h2>
214 <div className={classes.mainBody}>
215 <div className={classes.mainImage}>
216 <img
217 src={announcement.main.image[themeImage]}
218 alt=""
219 />
220 </div>
221 <div className={classes.mainText}>
222 <div
223 dangerouslySetInnerHTML={{
224 __html: marked(announcement.main.text, markedOptions),
225 }}
226 />
227 <div className={classes.mainCtaButton}>
228 <Button
229 label={announcement.main.cta.label}
230 onClick={() => {
231 const { analytics } = announcement.main.cta;
232 window.location.href = `#${announcement.main.cta.href}`;
233 gaEvent(analytics.category, analytics.action, announcement.main.cta.label);
234 }}
235 />
236 </div>
237 </div>
238 </div>
239 </div>
240 {announcement.spotlight && (
241 <div className={classes.spotlight}>
242 <div className={classes.spotlightTopicContainer}>
243 <h2 className={classes.spotlightTopic}>{announcement.spotlight.title}</h2>
244 <h3 className={classes.spotlightSubject}>{announcement.spotlight.subject}</h3>
245 </div>
246 <div className={classes.spotlightContentContainer}>
247 <div
248 dangerouslySetInnerHTML={{
249 __html: marked(announcement.spotlight.text, markedOptions),
250 }}
251 />
252 <div className={classes.mainCtaButton}>
253 <Button
254 label={announcement.spotlight.cta.label}
255 onClick={() => {
256 const { analytics } = announcement.spotlight.cta;
257 window.location.href = `#${announcement.spotlight.cta.href}`;
258 gaEvent(analytics.category, analytics.action, announcement.spotlight.cta.label);
259 }}
260 />
261 </div>
262 </div>
263 </div>
264 )}
265 </div>
266 )}
267 {changelog && (
268 <div className={classes.changelog}>
269 <h1 className={classes.headline}>
270 {intl.formatMessage(messages.headline, {
271 version: announcementsStore.currentVersion,
272 })}
273 </h1>
274 <div
275 dangerouslySetInnerHTML={{
276 __html: marked(changelog, markedOptions),
277 }}
278 />
279 </div>
280 )}
281 </div>
282 );
283 }
284}
285
286export default AnnouncementScreen;
diff --git a/src/features/announcements/index.js b/src/features/announcements/index.js
new file mode 100644
index 000000000..4658b976f
--- /dev/null
+++ b/src/features/announcements/index.js
@@ -0,0 +1,32 @@
1import { reaction } from 'mobx';
2import { AnnouncementsStore } from './store';
3
4const debug = require('debug')('Franz:feature:announcements');
5
6export const GA_CATEGORY_ANNOUNCEMENTS = 'Announcements';
7
8export const announcementsStore = new AnnouncementsStore();
9
10export default function initAnnouncements(stores, actions) {
11 // const { features } = stores;
12
13 // Toggle workspace feature
14 reaction(
15 () => (
16 true
17 // features.features.isAnnouncementsEnabled
18 ),
19 (isEnabled) => {
20 if (isEnabled) {
21 debug('Initializing `announcements` feature');
22 announcementsStore.start(stores, actions);
23 } else if (announcementsStore.isFeatureActive) {
24 debug('Disabling `announcements` feature');
25 announcementsStore.stop();
26 }
27 },
28 {
29 fireImmediately: true,
30 },
31 );
32}
diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js
new file mode 100644
index 000000000..7ecc0e346
--- /dev/null
+++ b/src/features/announcements/store.js
@@ -0,0 +1,144 @@
1import {
2 action,
3 computed,
4 observable,
5 reaction,
6} from 'mobx';
7import semver from 'semver';
8import localStorage from 'mobx-localstorage';
9
10import { FeatureStore } from '../utils/FeatureStore';
11import { GA_CATEGORY_ANNOUNCEMENTS } from '.';
12import { getAnnouncementRequest, getChangelogRequest, getCurrentVersionRequest } from './api';
13import { announcementActions } from './actions';
14import { createActionBindings } from '../utils/ActionBinding';
15import { createReactions } from '../../stores/lib/Reaction';
16import { gaEvent } from '../../lib/analytics';
17
18const LOCAL_STORAGE_KEY = 'announcements';
19
20const debug = require('debug')('Franz:feature:announcements:store');
21
22export class AnnouncementsStore extends FeatureStore {
23 @observable targetVersion = null;
24
25 @observable isAnnouncementVisible = false;
26
27 @observable isFeatureActive = false;
28
29 @computed get changelog() {
30 return getChangelogRequest.result;
31 }
32
33 @computed get announcement() {
34 return getAnnouncementRequest.result;
35 }
36
37 @computed get areNewsAvailable() {
38 const isChangelogAvailable = getChangelogRequest.wasExecuted && !!this.changelog;
39 const isAnnouncementAvailable = getAnnouncementRequest.wasExecuted && !!this.announcement;
40 return isChangelogAvailable || isAnnouncementAvailable;
41 }
42
43 @computed get settings() {
44 return localStorage.getItem(LOCAL_STORAGE_KEY) || {};
45 }
46
47 @computed get lastSeenAnnouncementVersion() {
48 return this.settings.lastSeenAnnouncementVersion || null;
49 }
50
51 @computed get currentVersion() {
52 return getCurrentVersionRequest.result;
53 }
54
55 @computed get isNewUser() {
56 return this.stores.settings.stats.appStarts <= 1;
57 }
58
59 async start(stores, actions) {
60 debug('AnnouncementsStore::start');
61 this.stores = stores;
62 this.actions = actions;
63 getCurrentVersionRequest.execute();
64
65 this._registerActions(createActionBindings([
66 [announcementActions.show, this._showAnnouncement],
67 ]));
68
69 this._reactions = createReactions([
70 this._fetchAnnouncements,
71 this._showAnnouncementToUsersWhoUpdatedApp,
72 ]);
73 this._registerReactions(this._reactions);
74 this.isFeatureActive = true;
75 }
76
77 stop() {
78 super.stop();
79 debug('AnnouncementsStore::stop');
80 this.isFeatureActive = false;
81 this.isAnnouncementVisible = false;
82 }
83
84 // ======= HELPERS ======= //
85
86 _updateSettings = (changes) => {
87 localStorage.setItem(LOCAL_STORAGE_KEY, {
88 ...this.settings,
89 ...changes,
90 });
91 };
92
93 // ======= ACTIONS ======= //
94
95 @action _showAnnouncement = ({ targetVersion } = {}) => {
96 if (!this.areNewsAvailable) return;
97 this.targetVersion = targetVersion || this.currentVersion;
98 this.isAnnouncementVisible = true;
99 this.actions.service.blurActive();
100 this._updateSettings({
101 lastSeenAnnouncementVersion: this.currentVersion,
102 });
103 const dispose = reaction(
104 () => this.stores.services.active,
105 () => {
106 this._hideAnnouncement();
107 dispose();
108 },
109 );
110
111 gaEvent(GA_CATEGORY_ANNOUNCEMENTS, 'show');
112 };
113
114 @action _hideAnnouncement() {
115 this.isAnnouncementVisible = false;
116 }
117
118 // ======= REACTIONS ========
119
120 _showAnnouncementToUsersWhoUpdatedApp = () => {
121 const { announcement, isNewUser } = this;
122 // Check if there is an announcement and on't show announcements to new users
123 if (!announcement || isNewUser) return;
124
125 // Check if the user has already used current version (= has seen the announcement)
126 const { currentVersion, lastSeenAnnouncementVersion } = this;
127 if (semver.gt(currentVersion, lastSeenAnnouncementVersion || '0.0.0')) {
128 debug(`${currentVersion} < ${lastSeenAnnouncementVersion}: announcement is shown`);
129 this._showAnnouncement();
130 }
131 };
132
133 _fetchAnnouncements = () => {
134 const targetVersion = this.targetVersion || this.currentVersion;
135 if (!targetVersion) return;
136 getChangelogRequest.execute(targetVersion);
137 // We only fetch announcements for current / older versions
138 if (targetVersion <= this.currentVersion) {
139 getAnnouncementRequest.execute(targetVersion);
140 } else {
141 getAnnouncementRequest.reset();
142 }
143 }
144}