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.js36
-rw-r--r--src/features/announcements/store.js144
5 files changed, 509 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..e7c5fe395
--- /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.targetVersion,
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..f14e7c9a5
--- /dev/null
+++ b/src/features/announcements/index.js
@@ -0,0 +1,36 @@
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 const ANNOUNCEMENTS_ROUTES = {
11 TARGET: '/announcements/:id',
12};
13
14export default function initAnnouncements(stores, actions) {
15 // const { features } = stores;
16
17 // Toggle workspace feature
18 reaction(
19 () => (
20 true
21 // features.features.isAnnouncementsEnabled
22 ),
23 (isEnabled) => {
24 if (isEnabled) {
25 debug('Initializing `announcements` feature');
26 announcementsStore.start(stores, actions);
27 } else if (announcementsStore.isFeatureActive) {
28 debug('Disabling `announcements` feature');
29 announcementsStore.stop();
30 }
31 },
32 {
33 fireImmediately: true,
34 },
35 );
36}
diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js
new file mode 100644
index 000000000..ad78a0979
--- /dev/null
+++ b/src/features/announcements/store.js
@@ -0,0 +1,144 @@
1import {
2 action,
3 computed,
4 observable,
5} from 'mobx';
6import semver from 'semver';
7import localStorage from 'mobx-localstorage';
8
9import { FeatureStore } from '../utils/FeatureStore';
10import { ANNOUNCEMENTS_ROUTES, GA_CATEGORY_ANNOUNCEMENTS } from '.';
11import { getAnnouncementRequest, getChangelogRequest, getCurrentVersionRequest } from './api';
12import { announcementActions } from './actions';
13import { createActionBindings } from '../utils/ActionBinding';
14import { createReactions } from '../../stores/lib/Reaction';
15import { gaEvent } from '../../lib/analytics';
16import { matchRoute } from '../../helpers/routing-helpers';
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 isFeatureActive = false;
26
27 @computed get changelog() {
28 return getChangelogRequest.result;
29 }
30
31 @computed get announcement() {
32 if (!this.stores || !getAnnouncementRequest.result) return null;
33 const { locale, defaultLocale } = this.stores.app;
34 const announcement = getAnnouncementRequest.result;
35 // User locale
36 if (announcement[locale]) return announcement[locale];
37 // Default locale
38 if (announcement[defaultLocale]) return announcement[defaultLocale];
39 // No locales specified
40 return announcement;
41 }
42
43 @computed get areNewsAvailable() {
44 const isChangelogAvailable = getChangelogRequest.wasExecuted && !!this.changelog;
45 const isAnnouncementAvailable = getAnnouncementRequest.wasExecuted && !!this.announcement;
46 return isChangelogAvailable || isAnnouncementAvailable;
47 }
48
49 @computed get settings() {
50 return localStorage.getItem(LOCAL_STORAGE_KEY) || {};
51 }
52
53 @computed get lastSeenAnnouncementVersion() {
54 return this.settings.lastSeenAnnouncementVersion || null;
55 }
56
57 @computed get currentVersion() {
58 return getCurrentVersionRequest.result;
59 }
60
61 @computed get isNewUser() {
62 return this.stores.settings.stats.appStarts <= 1;
63 }
64
65 async start(stores, actions) {
66 debug('AnnouncementsStore::start');
67 this.stores = stores;
68 this.actions = actions;
69 getCurrentVersionRequest.execute();
70
71 this._registerActions(createActionBindings([
72 [announcementActions.show, this._showAnnouncement],
73 ]));
74
75 this._reactions = createReactions([
76 this._showAnnouncementOnRouteMatch,
77 this._showAnnouncementToUsersWhoUpdatedApp,
78 this._fetchAnnouncements,
79 ]);
80 this._registerReactions(this._reactions);
81 this.isFeatureActive = true;
82 }
83
84 stop() {
85 super.stop();
86 debug('AnnouncementsStore::stop');
87 this.isFeatureActive = false;
88 }
89
90 // ======= HELPERS ======= //
91
92 _updateSettings = (changes) => {
93 localStorage.setItem(LOCAL_STORAGE_KEY, {
94 ...this.settings,
95 ...changes,
96 });
97 };
98
99 // ======= ACTIONS ======= //
100
101 @action _showAnnouncement = ({ targetVersion } = {}) => {
102 const { router } = this.stores;
103 this.targetVersion = targetVersion || this.currentVersion;
104 this._updateSettings({
105 lastSeenAnnouncementVersion: this.currentVersion,
106 });
107 const targetRoute = `/announcements/${this.targetVersion}`;
108 if (router.location.pathname !== targetRoute) {
109 this.stores.router.push(targetRoute);
110 }
111 gaEvent(GA_CATEGORY_ANNOUNCEMENTS, 'show');
112 };
113
114 // ======= REACTIONS ========
115
116 _showAnnouncementToUsersWhoUpdatedApp = () => {
117 const { announcement, isNewUser } = this;
118 // Check if there is an announcement and don't show announcements to new users
119 if (!announcement || isNewUser) return;
120
121 // Check if the user has already used current version (= has seen the announcement)
122 const { currentVersion, lastSeenAnnouncementVersion } = this;
123 if (semver.gt(currentVersion, lastSeenAnnouncementVersion || '0.0.0')) {
124 debug(`${currentVersion} > ${lastSeenAnnouncementVersion}: announcement is shown`);
125 this._showAnnouncement();
126 }
127 };
128
129 _fetchAnnouncements = () => {
130 const targetVersion = this.targetVersion || this.currentVersion;
131 if (!targetVersion) return;
132 getChangelogRequest.reset().execute(targetVersion);
133 getAnnouncementRequest.reset().execute(targetVersion);
134 };
135
136 _showAnnouncementOnRouteMatch = () => {
137 const { router } = this.stores;
138 const match = matchRoute(ANNOUNCEMENTS_ROUTES.TARGET, router.location.pathname);
139 if (match) {
140 const targetVersion = match.id;
141 this._showAnnouncement({ targetVersion });
142 }
143 }
144}