aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Stefan Malzner <stefan@adlk.io>2018-01-08 10:34:00 +0100
committerLibravatar Stefan Malzner <stefan@adlk.io>2018-01-08 10:34:00 +0100
commit91540e15eb2484a097587a38440b05897bb87638 (patch)
treedba412401da044ba203fcacb56018bfd7ed193eb
parentSort languages by name (diff)
parentAdd color to service icons (diff)
downloadferdium-app-91540e15eb2484a097587a38440b05897bb87638.tar.gz
ferdium-app-91540e15eb2484a097587a38440b05897bb87638.tar.zst
ferdium-app-91540e15eb2484a097587a38440b05897bb87638.zip
Merge branch 'develop' into i18n
# Conflicts: # src/i18n/languages.js
-rw-r--r--electron-builder.yml4
-rw-r--r--gulpfile.babel.js4
-rw-r--r--package.json10
-rw-r--r--src/actions/app.js1
-rw-r--r--src/actions/service.js3
-rw-r--r--src/api/LocalApi.js8
-rw-r--r--src/api/ServicesApi.js7
-rw-r--r--src/api/server/LocalApi.js36
-rw-r--r--src/api/server/ServerApi.js63
-rw-r--r--src/app.js2
-rw-r--r--src/components/services/content/ServiceWebview.js2
-rw-r--r--src/components/services/tabs/TabItem.js2
-rw-r--r--src/components/settings/recipes/RecipesDashboard.js20
-rw-r--r--src/components/settings/services/EditServiceForm.js100
-rw-r--r--src/components/settings/services/ServicesDashboard.js42
-rw-r--r--src/components/settings/settings/EditSettingsForm.js43
-rw-r--r--src/components/ui/ImageUpload.js108
-rw-r--r--src/components/ui/SearchInput.js48
-rw-r--r--src/components/ui/SubscriptionPopup.js1
-rw-r--r--src/config.js3
-rw-r--r--src/containers/settings/EditServiceScreen.js36
-rw-r--r--src/containers/settings/EditSettingsScreen.js17
-rw-r--r--src/containers/settings/ServicesScreen.js1
-rw-r--r--src/electron/deepLinking.js7
-rw-r--r--src/electron/ipc-api/autoUpdate.js3
-rw-r--r--src/helpers/async-helpers.js5
-rw-r--r--src/helpers/service-helpers.js20
-rw-r--r--src/helpers/webview-ime-focus-helpers.js38
-rw-r--r--src/i18n/locales/en-US.json12
-rw-r--r--src/index.html18
-rw-r--r--src/index.js25
-rw-r--r--src/lib/Menu.js2
-rw-r--r--src/models/Recipe.js9
-rw-r--r--src/models/Service.js18
-rw-r--r--src/models/Settings.js6
-rw-r--r--src/stores/AppStore.js76
-rw-r--r--src/stores/ServicesStore.js50
-rw-r--r--src/stores/SettingsStore.js2
-rw-r--r--src/styles/button.scss12
-rw-r--r--src/styles/content-tabs.scss12
-rw-r--r--src/styles/image-upload.scss91
-rw-r--r--src/styles/input.scss4
-rw-r--r--src/styles/main.scss1
-rw-r--r--src/styles/searchInput.scss16
-rw-r--r--src/styles/services.scss1
-rw-r--r--src/styles/settings.scss70
-rw-r--r--src/styles/welcome.scss7
-rw-r--r--src/webview/ime.js10
-rw-r--r--src/webview/lib/RecipeWebview.js4
-rw-r--r--src/webview/notifications.js4
-rw-r--r--src/webview/plugin.js45
-rw-r--r--src/webview/spellchecker.js65
-rw-r--r--yarn.lock150
53 files changed, 1097 insertions, 247 deletions
diff --git a/electron-builder.yml b/electron-builder.yml
index 03e59e462..96bd63cc2 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -34,3 +34,7 @@ linux:
34nsis: 34nsis:
35 perMachine: false 35 perMachine: false
36 oneClick: true 36 oneClick: true
37
38protocols:
39 name: Franz
40 schemes: [franz]
diff --git a/gulpfile.babel.js b/gulpfile.babel.js
index d947974b3..95b026f66 100644
--- a/gulpfile.babel.js
+++ b/gulpfile.babel.js
@@ -110,7 +110,9 @@ export function watch() {
110} 110}
111 111
112export function webserver() { 112export function webserver() {
113 gulp.src(paths.dest) 113 gulp.src([
114 paths.dest,
115 ])
114 .pipe(server({ 116 .pipe(server({
115 livereload: true, 117 livereload: true,
116 })); 118 }));
diff --git a/package.json b/package.json
index 8a5eee7b2..5f5dd4182 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
6 "description": "Messaging app for WhatsApp, Slack, Telegram, HipChat, Hangouts and many many more.", 6 "description": "Messaging app for WhatsApp, Slack, Telegram, HipChat, Hangouts and many many more.",
7 "copyright": "adlk x franz - Stefan Malzner", 7 "copyright": "adlk x franz - Stefan Malzner",
8 "main": "index.js", 8 "main": "index.js",
9 "homepage": "https://meetfranz.com",
9 "repository": "https://github.com/meetfranz/franz.git", 10 "repository": "https://github.com/meetfranz/franz.git",
10 "private": true, 11 "private": true,
11 "scripts": { 12 "scripts": {
@@ -33,8 +34,9 @@
33 "babel-polyfill": "^6.23.0", 34 "babel-polyfill": "^6.23.0",
34 "babel-runtime": "^6.23.0", 35 "babel-runtime": "^6.23.0",
35 "classnames": "^2.2.5", 36 "classnames": "^2.2.5",
37 "du": "^0.1.0",
36 "electron-fetch": "^1.1.0", 38 "electron-fetch": "^1.1.0",
37 "electron-spellchecker": "^1.2.0", 39 "electron-spellchecker": "^1.1.2",
38 "electron-updater": "^2.4.3", 40 "electron-updater": "^2.4.3",
39 "electron-window-state": "^4.1.0", 41 "electron-window-state": "^4.1.0",
40 "fs-extra": "^3.0.1", 42 "fs-extra": "^3.0.1",
@@ -49,15 +51,17 @@
49 "mkdirp": "^0.5.1", 51 "mkdirp": "^0.5.1",
50 "mobx": "^3.1.0", 52 "mobx": "^3.1.0",
51 "mobx-react": "^4.1.0", 53 "mobx-react": "^4.1.0",
52 "mobx-react-form": "1.24.0", 54 "mobx-react-form": "^1.32.2",
53 "mobx-react-router": "^3.1.2", 55 "mobx-react-router": "^3.1.2",
54 "moment": "^2.17.1", 56 "moment": "^2.17.1",
55 "normalize-url": "^1.9.1", 57 "normalize-url": "^1.9.1",
58 "pretty-bytes": "^4.0.2",
56 "prop-types": "^15.5.10", 59 "prop-types": "^15.5.10",
57 "prop-types-extended": "^0.2.1", 60 "prop-types-extended": "^0.2.1",
58 "react": "^15.4.1", 61 "react": "^15.4.1",
59 "react-addons-css-transition-group": "^15.4.2", 62 "react-addons-css-transition-group": "^15.4.2",
60 "react-dom": "^15.4.1", 63 "react-dom": "^15.4.1",
64 "react-dropzone": "^4.2.1",
61 "react-electron-web-view": "^2.0.1", 65 "react-electron-web-view": "^2.0.1",
62 "react-intl": "^2.3.0", 66 "react-intl": "^2.3.0",
63 "react-loader": "^2.4.0", 67 "react-loader": "^2.4.0",
@@ -103,7 +107,7 @@
103 "gulp-sass": "^3.1.0", 107 "gulp-sass": "^3.1.0",
104 "gulp-sass-variables": "^1.1.1", 108 "gulp-sass-variables": "^1.1.1",
105 "gulp-server-livereload": "^1.9.2", 109 "gulp-server-livereload": "^1.9.2",
106 "node-sass": "^4.5.3" 110 "node-sass": "^4.7.2"
107 }, 111 },
108 "config": { 112 "config": {
109 "commitizen": { 113 "commitizen": {
diff --git a/src/actions/app.js b/src/actions/app.js
index e4f648fc9..e6f7f22ba 100644
--- a/src/actions/app.js
+++ b/src/actions/app.js
@@ -25,4 +25,5 @@ export default {
25 overrideSystemMute: PropTypes.bool, 25 overrideSystemMute: PropTypes.bool,
26 }, 26 },
27 toggleMuteApp: {}, 27 toggleMuteApp: {},
28 clearAllCache: {},
28}; 29};
diff --git a/src/actions/service.js b/src/actions/service.js
index e3100e986..5d483b12a 100644
--- a/src/actions/service.js
+++ b/src/actions/service.js
@@ -25,6 +25,9 @@ export default {
25 serviceId: PropTypes.string.isRequired, 25 serviceId: PropTypes.string.isRequired,
26 redirect: PropTypes.string, 26 redirect: PropTypes.string,
27 }, 27 },
28 clearCache: {
29 serviceId: PropTypes.string.isRequired,
30 },
28 setUnreadMessageCount: { 31 setUnreadMessageCount: {
29 serviceId: PropTypes.string.isRequired, 32 serviceId: PropTypes.string.isRequired,
30 count: PropTypes.object.isRequired, 33 count: PropTypes.object.isRequired,
diff --git a/src/api/LocalApi.js b/src/api/LocalApi.js
index 6f2b049d6..3f84f8a0b 100644
--- a/src/api/LocalApi.js
+++ b/src/api/LocalApi.js
@@ -15,4 +15,12 @@ export default class LocalApi {
15 removeKey(key) { 15 removeKey(key) {
16 return this.local.removeKey(key); 16 return this.local.removeKey(key);
17 } 17 }
18
19 getAppCacheSize() {
20 return this.local.getAppCacheSize();
21 }
22
23 clearAppCache() {
24 return this.local.clearAppCache();
25 }
18} 26}
diff --git a/src/api/ServicesApi.js b/src/api/ServicesApi.js
index 3cb40ba0d..36ed9482f 100644
--- a/src/api/ServicesApi.js
+++ b/src/api/ServicesApi.js
@@ -1,5 +1,6 @@
1export default class ServicesApi { 1export default class ServicesApi {
2 constructor(server) { 2 constructor(server, local) {
3 this.local = local;
3 this.server = server; 4 this.server = server;
4 } 5 }
5 6
@@ -30,4 +31,8 @@ export default class ServicesApi {
30 reorder(data) { 31 reorder(data) {
31 return this.server.reorderService(data); 32 return this.server.reorderService(data);
32 } 33 }
34
35 clearCache(serviceId) {
36 return this.local.clearCache(serviceId);
37 }
33} 38}
diff --git a/src/api/server/LocalApi.js b/src/api/server/LocalApi.js
index eba236f16..e95d750ac 100644
--- a/src/api/server/LocalApi.js
+++ b/src/api/server/LocalApi.js
@@ -1,4 +1,9 @@
1import SettingsModel from '../../models/Settings'; 1import { remote } from 'electron';
2import du from 'du';
3
4import { getServicePartitionsDirectory } from '../../helpers/service-helpers.js';
5
6const { session } = remote;
2 7
3export default class LocalApi { 8export default class LocalApi {
4 // App 9 // App
@@ -15,7 +20,7 @@ export default class LocalApi {
15 async getAppSettings() { 20 async getAppSettings() {
16 const settingsString = localStorage.getItem('app'); 21 const settingsString = localStorage.getItem('app');
17 try { 22 try {
18 const settings = new SettingsModel(JSON.parse(settingsString) || {}); 23 const settings = JSON.parse(settingsString) || {};
19 console.debug('LocalApi::getAppSettings resolves', settings); 24 console.debug('LocalApi::getAppSettings resolves', settings);
20 25
21 return settings; 26 return settings;
@@ -32,4 +37,31 @@ export default class LocalApi {
32 localStorage.setItem('app', JSON.stringify(settings)); 37 localStorage.setItem('app', JSON.stringify(settings));
33 } 38 }
34 } 39 }
40
41 // Services
42 async getAppCacheSize() {
43 const partitionsDir = getServicePartitionsDirectory();
44 return new Promise((resolve, reject) => {
45 du(partitionsDir, (err, size) => {
46 if (err) reject(err);
47
48 console.debug('LocalApi::getAppCacheSize resolves', size);
49 resolve(size);
50 });
51 });
52 }
53
54 async clearCache(serviceId) {
55 const s = session.fromPartition(`persist:service-${serviceId}`);
56
57 console.debug('LocalApi::clearCache resolves', serviceId);
58 return new Promise(resolve => s.clearCache(resolve));
59 }
60
61 async clearAppCache() {
62 const s = session.defaultSession;
63
64 console.debug('LocalApi::clearCache clearAppCache');
65 return new Promise(resolve => s.clearCache(resolve));
66 }
35} 67}
diff --git a/src/api/server/ServerApi.js b/src/api/server/ServerApi.js
index 644bf20cd..a684ff98b 100644
--- a/src/api/server/ServerApi.js
+++ b/src/api/server/ServerApi.js
@@ -12,6 +12,8 @@ import NewsModel from '../../models/News';
12import UserModel from '../../models/User'; 12import UserModel from '../../models/User';
13import OrderModel from '../../models/Order'; 13import OrderModel from '../../models/Order';
14 14
15import { sleep } from '../../helpers/async-helpers';
16
15import { API } from '../../environment'; 17import { API } from '../../environment';
16 18
17import { 19import {
@@ -20,6 +22,10 @@ import {
20 loadRecipeConfig, 22 loadRecipeConfig,
21} from '../../helpers/recipe-helpers'; 23} from '../../helpers/recipe-helpers';
22 24
25import {
26 removeServicePartitionDirectory,
27} from '../../helpers/service-helpers.js';
28
23module.paths.unshift( 29module.paths.unshift(
24 getDevRecipeDirectory(), 30 getDevRecipeDirectory(),
25 getRecipeDirectory(), 31 getRecipeDirectory(),
@@ -165,27 +171,65 @@ export default class ServerApi {
165 throw request; 171 throw request;
166 } 172 }
167 const serviceData = await request.json(); 173 const serviceData = await request.json();
174
175 if (data.iconFile) {
176 const iconUrl = await this.uploadServiceIcon(serviceData.data.id, data.iconFile);
177
178 serviceData.data.iconUrl = iconUrl;
179 }
180
168 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) }); 181 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) });
169 182
170 console.debug('ServerApi::createService resolves', service); 183 console.debug('ServerApi::createService resolves', service);
171 return service; 184 return service;
172 } 185 }
173 186
174 async updateService(recipeId, data) { 187 async updateService(serviceId, rawData) {
175 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${recipeId}`, this._prepareAuthRequest({ 188 const data = rawData;
189
190 if (data.iconFile) {
191 await this.uploadServiceIcon(serviceId, data.iconFile);
192 }
193
194 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${serviceId}`, this._prepareAuthRequest({
176 method: 'PUT', 195 method: 'PUT',
177 body: JSON.stringify(data), 196 body: JSON.stringify(data),
178 })); 197 }));
198
179 if (!request.ok) { 199 if (!request.ok) {
180 throw request; 200 throw request;
181 } 201 }
202
182 const serviceData = await request.json(); 203 const serviceData = await request.json();
204
183 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) }); 205 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) });
184 206
185 console.debug('ServerApi::updateService resolves', service); 207 console.debug('ServerApi::updateService resolves', service);
186 return service; 208 return service;
187 } 209 }
188 210
211 async uploadServiceIcon(serviceId, icon) {
212 const formData = new FormData();
213 formData.append('icon', icon);
214
215 const requestData = this._prepareAuthRequest({
216 method: 'PUT',
217 body: formData,
218 });
219
220 delete requestData.headers['Content-Type'];
221
222 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${serviceId}`, requestData);
223
224 if (!request.ok) {
225 throw request;
226 }
227
228 const serviceData = await request.json();
229
230 return serviceData.data.iconUrl;
231 }
232
189 async reorderService(data) { 233 async reorderService(data) {
190 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/reorder`, this._prepareAuthRequest({ 234 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/reorder`, this._prepareAuthRequest({
191 method: 'PUT', 235 method: 'PUT',
@@ -208,6 +252,8 @@ export default class ServerApi {
208 } 252 }
209 const data = await request.json(); 253 const data = await request.json();
210 254
255 removeServicePartitionDirectory(id, true);
256
211 console.debug('ServerApi::deleteService resolves', data); 257 console.debug('ServerApi::deleteService resolves', data);
212 return data; 258 return data;
213 } 259 }
@@ -303,18 +349,25 @@ export default class ServerApi {
303 349
304 fs.ensureDirSync(recipeTempDirectory); 350 fs.ensureDirSync(recipeTempDirectory);
305 const res = await fetch(packageUrl); 351 const res = await fetch(packageUrl);
352 console.debug('Recipe downloaded', recipeId);
306 const buffer = await res.buffer(); 353 const buffer = await res.buffer();
307 fs.writeFileSync(archivePath, buffer); 354 fs.writeFileSync(archivePath, buffer);
308 355
309 tar.x({ 356 await sleep(10);
357
358 await tar.x({
310 file: archivePath, 359 file: archivePath,
311 cwd: recipeTempDirectory, 360 cwd: recipeTempDirectory,
312 sync: true, 361 preservePaths: true,
362 unlink: true,
363 preserveOwner: false,
364 onwarn: x => console.log('warn', recipeId, x),
313 }); 365 });
314 366
367 await sleep(10);
368
315 const { id } = fs.readJsonSync(path.join(recipeTempDirectory, 'package.json')); 369 const { id } = fs.readJsonSync(path.join(recipeTempDirectory, 'package.json'));
316 const recipeDirectory = path.join(recipesDirectory, id); 370 const recipeDirectory = path.join(recipesDirectory, id);
317
318 fs.copySync(recipeTempDirectory, recipeDirectory); 371 fs.copySync(recipeTempDirectory, recipeDirectory);
319 fs.remove(recipeTempDirectory); 372 fs.remove(recipeTempDirectory);
320 fs.remove(path.join(recipesDirectory, recipeId, 'recipe.tar.gz')); 373 fs.remove(path.join(recipesDirectory, recipeId, 'recipe.tar.gz'));
diff --git a/src/app.js b/src/app.js
index a0b88611c..8e62776d2 100644
--- a/src/app.js
+++ b/src/app.js
@@ -105,3 +105,5 @@ window.addEventListener('load', () => {
105// Prevent drag and drop into window from redirecting 105// Prevent drag and drop into window from redirecting
106window.addEventListener('dragover', event => event.preventDefault()); 106window.addEventListener('dragover', event => event.preventDefault());
107window.addEventListener('drop', event => event.preventDefault()); 107window.addEventListener('drop', event => event.preventDefault());
108window.addEventListener('dragover', event => event.stopPropagation());
109window.addEventListener('drop', event => event.stopPropagation());
diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js
index faa356d3d..c146abf4e 100644
--- a/src/components/services/content/ServiceWebview.js
+++ b/src/components/services/content/ServiceWebview.js
@@ -65,6 +65,7 @@ export default class ServiceWebview extends Component {
65 65
66 const webviewClasses = classnames({ 66 const webviewClasses = classnames({
67 services__webview: true, 67 services__webview: true,
68 'services__webview-wrapper': true,
68 'is-active': service.isActive, 69 'is-active': service.isActive,
69 'services__webview--force-repaint': this.state.forceRepaint, 70 'services__webview--force-repaint': this.state.forceRepaint,
70 }); 71 });
@@ -105,7 +106,6 @@ export default class ServiceWebview extends Component {
105 onUpdateTargetUrl={this.updateTargetUrl} 106 onUpdateTargetUrl={this.updateTargetUrl}
106 useragent={service.userAgent} 107 useragent={service.userAgent}
107 muted={isAppMuted || service.isMuted} 108 muted={isAppMuted || service.isMuted}
108 disablewebsecurity
109 allowpopups 109 allowpopups
110 /> 110 />
111 )} 111 )}
diff --git a/src/components/services/tabs/TabItem.js b/src/components/services/tabs/TabItem.js
index 8403d9462..7aed8fda7 100644
--- a/src/components/services/tabs/TabItem.js
+++ b/src/components/services/tabs/TabItem.js
@@ -126,7 +126,7 @@ class TabItem extends Component {
126 const menu = Menu.buildFromTemplate(menuTemplate); 126 const menu = Menu.buildFromTemplate(menuTemplate);
127 127
128 let notificationBadge = null; 128 let notificationBadge = null;
129 if ((showMessageBadgeWhenMutedSetting || service.isNotificationEnabled) && showMessageBadgesEvenWhenMuted) { 129 if ((showMessageBadgeWhenMutedSetting || service.isNotificationEnabled) && showMessageBadgesEvenWhenMuted && service.isBadgeEnabled) {
130 notificationBadge = ( 130 notificationBadge = (
131 <span> 131 <span>
132 {service.unreadDirectMessageCount > 0 && ( 132 {service.unreadDirectMessageCount > 0 && (
diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js
index b6ade5da4..4610c69a5 100644
--- a/src/components/settings/recipes/RecipesDashboard.js
+++ b/src/components/settings/recipes/RecipesDashboard.js
@@ -16,6 +16,10 @@ const messages = defineMessages({
16 id: 'settings.recipes.headline', 16 id: 'settings.recipes.headline',
17 defaultMessage: '!!!Available Services', 17 defaultMessage: '!!!Available Services',
18 }, 18 },
19 searchService: {
20 id: 'settings.searchService',
21 defaultMessage: '!!!Search service',
22 },
19 mostPopularRecipes: { 23 mostPopularRecipes: {
20 id: 'settings.recipes.mostPopular', 24 id: 'settings.recipes.mostPopular',
21 defaultMessage: '!!!Most popular', 25 defaultMessage: '!!!Most popular',
@@ -81,13 +85,7 @@ export default class RecipesDashboard extends Component {
81 return ( 85 return (
82 <div className="settings__main"> 86 <div className="settings__main">
83 <div className="settings__header"> 87 <div className="settings__header">
84 <SearchInput 88 <h1>{intl.formatMessage(messages.headline)}</h1>
85 className="settings__search-header"
86 defaultValue={intl.formatMessage(messages.headline)}
87 onChange={e => searchRecipes(e)}
88 onReset={() => resetSearch()}
89 throttle
90 />
91 </div> 89 </div>
92 <div className="settings__body recipes"> 90 <div className="settings__body recipes">
93 {serviceStatus.length > 0 && serviceStatus.includes('created') && ( 91 {serviceStatus.length > 0 && serviceStatus.includes('created') && (
@@ -101,7 +99,13 @@ export default class RecipesDashboard extends Component {
101 </Infobox> 99 </Infobox>
102 </Appear> 100 </Appear>
103 )} 101 )}
104 {/* {!searchNeedle && ( */} 102 <SearchInput
103 placeholder={intl.formatMessage(messages.searchService)}
104 onChange={e => searchRecipes(e)}
105 onReset={() => resetSearch()}
106 autoFocus
107 throttle
108 />
105 <div className="recipes__navigation"> 109 <div className="recipes__navigation">
106 <Link 110 <Link
107 to="/settings/recipes" 111 to="/settings/recipes"
diff --git a/src/components/settings/services/EditServiceForm.js b/src/components/settings/services/EditServiceForm.js
index 36cefe87c..f6f2df2f3 100644
--- a/src/components/settings/services/EditServiceForm.js
+++ b/src/components/settings/services/EditServiceForm.js
@@ -13,6 +13,7 @@ import Tabs, { TabItem } from '../../ui/Tabs';
13import Input from '../../ui/Input'; 13import Input from '../../ui/Input';
14import Toggle from '../../ui/Toggle'; 14import Toggle from '../../ui/Toggle';
15import Button from '../../ui/Button'; 15import Button from '../../ui/Button';
16import ImageUpload from '../../ui/ImageUpload';
16 17
17const messages = defineMessages({ 18const messages = defineMessages({
18 saveService: { 19 saveService: {
@@ -47,6 +48,10 @@ const messages = defineMessages({
47 id: 'settings.service.form.tabOnPremise', 48 id: 'settings.service.form.tabOnPremise',
48 defaultMessage: '!!!Self hosted ⭐️', 49 defaultMessage: '!!!Self hosted ⭐️',
49 }, 50 },
51 useHostedService: {
52 id: 'settings.service.form.useHostedService',
53 defaultMessage: '!!!Use the hosted {name} service.',
54 },
50 customUrlValidationError: { 55 customUrlValidationError: {
51 id: 'settings.service.form.customUrlValidationError', 56 id: 'settings.service.form.customUrlValidationError',
52 defaultMessage: '!!!Could not validate custom {name} server.', 57 defaultMessage: '!!!Could not validate custom {name} server.',
@@ -67,6 +72,26 @@ const messages = defineMessages({
67 id: 'settings.service.form.isMutedInfo', 72 id: 'settings.service.form.isMutedInfo',
68 defaultMessage: '!!!When disabled, all notification sounds and audio playback are muted', 73 defaultMessage: '!!!When disabled, all notification sounds and audio playback are muted',
69 }, 74 },
75 headlineNotifications: {
76 id: 'settings.service.form.headlineNotifications',
77 defaultMessage: '!!!Notifications',
78 },
79 headlineBadges: {
80 id: 'settings.service.form.headlineBadges',
81 defaultMessage: '!!!Unread message badges',
82 },
83 headlineGeneral: {
84 id: 'settings.service.form.headlineGeneral',
85 defaultMessage: '!!!General',
86 },
87 iconDelete: {
88 id: 'settings.service.form.iconDelete',
89 defaultMessage: '!!!Delete',
90 },
91 iconUpload: {
92 id: 'settings.service.form.iconUpload',
93 defaultMessage: '!!!Drop your image, or click here',
94 },
70}); 95});
71 96
72@observer 97@observer
@@ -108,9 +133,13 @@ export default class EditServiceForm extends Component {
108 this.props.form.submit({ 133 this.props.form.submit({
109 onSuccess: async (form) => { 134 onSuccess: async (form) => {
110 const values = form.values(); 135 const values = form.values();
111
112 let isValid = true; 136 let isValid = true;
113 137
138 const files = form.$('customIcon').files;
139 if (files) {
140 values.iconFile = files[0];
141 }
142
114 if (recipe.validateUrl && values.customUrl) { 143 if (recipe.validateUrl && values.customUrl) {
115 this.setState({ isValidatingCustomUrl: true }); 144 this.setState({ isValidatingCustomUrl: true });
116 try { 145 try {
@@ -166,6 +195,13 @@ export default class EditServiceForm extends Component {
166 /> 195 />
167 ); 196 );
168 197
198 let activeTabIndex = 0;
199 if (recipe.hasHostedOption && service.team) {
200 activeTabIndex = 1;
201 } else if (recipe.hasHostedOption && service.customUrl) {
202 activeTabIndex = 2;
203 }
204
169 return ( 205 return (
170 <div className="settings__main"> 206 <div className="settings__main">
171 <div className="settings__header"> 207 <div className="settings__header">
@@ -195,14 +231,25 @@ export default class EditServiceForm extends Component {
195 </div> 231 </div>
196 <div className="settings__body"> 232 <div className="settings__body">
197 <form onSubmit={e => this.submit(e)} id="form"> 233 <form onSubmit={e => this.submit(e)} id="form">
198 <Input field={form.$('name')} focus /> 234 <div className="service-name">
235 <Input field={form.$('name')} focus />
236 </div>
199 {(recipe.hasTeamId || recipe.hasCustomUrl) && ( 237 {(recipe.hasTeamId || recipe.hasCustomUrl) && (
200 <Tabs 238 <Tabs
201 active={service.customUrl ? 1 : 0} 239 active={activeTabIndex}
202 > 240 >
241 {recipe.hasHostedOption && (
242 <TabItem title={recipe.name}>
243 {intl.formatMessage(messages.useHostedService, { name: recipe.name })}
244 </TabItem>
245 )}
203 {recipe.hasTeamId && ( 246 {recipe.hasTeamId && (
204 <TabItem title={intl.formatMessage(messages.tabHosted)}> 247 <TabItem title={intl.formatMessage(messages.tabHosted)}>
205 <Input field={form.$('team')} suffix={recipe.urlInputSuffix} /> 248 <Input
249 field={form.$('team')}
250 prefix={recipe.urlInputPrefix}
251 suffix={recipe.urlInputSuffix}
252 />
206 </TabItem> 253 </TabItem>
207 )} 254 )}
208 {recipe.hasCustomUrl && ( 255 {recipe.hasCustomUrl && (
@@ -230,21 +277,42 @@ export default class EditServiceForm extends Component {
230 )} 277 )}
231 </Tabs> 278 </Tabs>
232 )} 279 )}
233 <div className="settings__options"> 280 <div className="service-flex-grid">
234 <Toggle field={form.$('isNotificationEnabled')} /> 281 <div className="settings__options">
235 {recipe.hasIndirectMessages && ( 282 <div className="settings__settings-group">
236 <div> 283 <h3>{intl.formatMessage(messages.headlineNotifications)}</h3>
237 <Toggle field={form.$('isIndirectMessageBadgeEnabled')} /> 284 <Toggle field={form.$('isNotificationEnabled')} />
285 <Toggle field={form.$('isMuted')} />
238 <p className="settings__help"> 286 <p className="settings__help">
239 {intl.formatMessage(messages.indirectMessageInfo)} 287 {intl.formatMessage(messages.isMutedInfo)}
240 </p> 288 </p>
241 </div> 289 </div>
242 )} 290
243 <Toggle field={form.$('isMuted')} /> 291 <div className="settings__settings-group">
244 <p className="settings__help"> 292 <h3>{intl.formatMessage(messages.headlineBadges)}</h3>
245 {intl.formatMessage(messages.isMutedInfo)} 293 <Toggle field={form.$('isBadgeEnabled')} />
246 </p> 294 {recipe.hasIndirectMessages && form.$('isBadgeEnabled').value && (
247 <Toggle field={form.$('isEnabled')} /> 295 <div>
296 <Toggle field={form.$('isIndirectMessageBadgeEnabled')} />
297 <p className="settings__help">
298 {intl.formatMessage(messages.indirectMessageInfo)}
299 </p>
300 </div>
301 )}
302 </div>
303
304 <div className="settings__settings-group">
305 <h3>{intl.formatMessage(messages.headlineGeneral)}</h3>
306 <Toggle field={form.$('isEnabled')} />
307 </div>
308 </div>
309 <div className="service-icon">
310 <ImageUpload
311 field={form.$('customIcon')}
312 textDelete={intl.formatMessage(messages.iconDelete)}
313 textUpload={intl.formatMessage(messages.iconUpload)}
314 />
315 </div>
248 </div> 316 </div>
249 {recipe.message && ( 317 {recipe.message && (
250 <p className="settings__message"> 318 <p className="settings__message">
diff --git a/src/components/settings/services/ServicesDashboard.js b/src/components/settings/services/ServicesDashboard.js
index 5f146b5f3..20e451f01 100644
--- a/src/components/settings/services/ServicesDashboard.js
+++ b/src/components/settings/services/ServicesDashboard.js
@@ -15,10 +15,18 @@ const messages = defineMessages({
15 id: 'settings.services.headline', 15 id: 'settings.services.headline',
16 defaultMessage: '!!!Your services', 16 defaultMessage: '!!!Your services',
17 }, 17 },
18 searchService: {
19 id: 'settings.searchService',
20 defaultMessage: '!!!Search service',
21 },
18 noServicesAdded: { 22 noServicesAdded: {
19 id: 'settings.services.noServicesAdded', 23 id: 'settings.services.noServicesAdded',
20 defaultMessage: '!!!You haven\'t added any services yet.', 24 defaultMessage: '!!!You haven\'t added any services yet.',
21 }, 25 },
26 noServiceFound: {
27 id: 'settings.recipes.nothingFound',
28 defaultMessage: '!!!Sorry, but no service matched your search term.',
29 },
22 discoverServices: { 30 discoverServices: {
23 id: 'settings.services.discoverServices', 31 id: 'settings.services.discoverServices',
24 defaultMessage: '!!!Discover services', 32 defaultMessage: '!!!Discover services',
@@ -53,7 +61,13 @@ export default class ServicesDashboard extends Component {
53 servicesRequestFailed: PropTypes.bool.isRequired, 61 servicesRequestFailed: PropTypes.bool.isRequired,
54 retryServicesRequest: PropTypes.func.isRequired, 62 retryServicesRequest: PropTypes.func.isRequired,
55 status: MobxPropTypes.arrayOrObservableArray.isRequired, 63 status: MobxPropTypes.arrayOrObservableArray.isRequired,
64 searchNeedle: PropTypes.string,
56 }; 65 };
66
67 static defaultProps = {
68 searchNeedle: '',
69 }
70
57 static contextTypes = { 71 static contextTypes = {
58 intl: intlShape, 72 intl: intlShape,
59 }; 73 };
@@ -69,20 +83,24 @@ export default class ServicesDashboard extends Component {
69 servicesRequestFailed, 83 servicesRequestFailed,
70 retryServicesRequest, 84 retryServicesRequest,
71 status, 85 status,
86 searchNeedle,
72 } = this.props; 87 } = this.props;
73 const { intl } = this.context; 88 const { intl } = this.context;
74 89
75 return ( 90 return (
76 <div className="settings__main"> 91 <div className="settings__main">
77 <div className="settings__header"> 92 <div className="settings__header">
78 <SearchInput 93 <h1>{intl.formatMessage(messages.headline)}</h1>
79 className="settings__search-header"
80 defaultValue={intl.formatMessage(messages.headline)}
81 onChange={needle => filterServices({ needle })}
82 onReset={() => resetFilter()}
83 />
84 </div> 94 </div>
85 <div className="settings__body"> 95 <div className="settings__body">
96 {!isLoading && (
97 <SearchInput
98 placeholder={intl.formatMessage(messages.searchService)}
99 onChange={needle => filterServices({ needle })}
100 onReset={() => resetFilter()}
101 autoFocus
102 />
103 )}
86 {!isLoading && servicesRequestFailed && ( 104 {!isLoading && servicesRequestFailed && (
87 <div> 105 <div>
88 <Infobox 106 <Infobox
@@ -121,7 +139,7 @@ export default class ServicesDashboard extends Component {
121 </Appear> 139 </Appear>
122 )} 140 )}
123 141
124 {!isLoading && services.length === 0 && ( 142 {!isLoading && services.length === 0 && !searchNeedle && (
125 <div className="align-middle settings__empty-state"> 143 <div className="align-middle settings__empty-state">
126 <p className="settings__empty-text"> 144 <p className="settings__empty-text">
127 <span className="emoji"> 145 <span className="emoji">
@@ -132,6 +150,16 @@ export default class ServicesDashboard extends Component {
132 <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link> 150 <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link>
133 </div> 151 </div>
134 )} 152 )}
153 {!isLoading && services.length === 0 && searchNeedle && (
154 <div className="align-middle settings__empty-state">
155 <p className="settings__empty-text">
156 <span className="emoji">
157 <img src="./assets/images/emoji/dontknow.png" alt="" />
158 </span>
159 {intl.formatMessage(messages.noServiceFound)}
160 </p>
161 </div>
162 )}
135 {isLoading ? ( 163 {isLoading ? (
136 <Loader /> 164 <Loader />
137 ) : ( 165 ) : (
diff --git a/src/components/settings/settings/EditSettingsForm.js b/src/components/settings/settings/EditSettingsForm.js
index 878e46d6d..72aa5a8af 100644
--- a/src/components/settings/settings/EditSettingsForm.js
+++ b/src/components/settings/settings/EditSettingsForm.js
@@ -40,6 +40,18 @@ const messages = defineMessages({
40 id: 'settings.app.translationHelp', 40 id: 'settings.app.translationHelp',
41 defaultMessage: '!!!Help us to translate Franz into your language.', 41 defaultMessage: '!!!Help us to translate Franz into your language.',
42 }, 42 },
43 subheadlineCache: {
44 id: 'settings.app.subheadlineCache',
45 defaultMessage: '!!!Cache',
46 },
47 cacheInfo: {
48 id: 'settings.app.cacheInfo',
49 defaultMessage: '!!!Franz cache is currently using {size} of disk space.',
50 },
51 buttonClearAllCache: {
52 id: 'settings.app.buttonClearAllCache',
53 defaultMessage: '!!!Clear cache',
54 },
43 buttonSearchForUpdate: { 55 buttonSearchForUpdate: {
44 id: 'settings.app.buttonSearchForUpdate', 56 id: 'settings.app.buttonSearchForUpdate',
45 defaultMessage: '!!!Check for updates', 57 defaultMessage: '!!!Check for updates',
@@ -64,10 +76,6 @@ const messages = defineMessages({
64 id: 'settings.app.currentVersion', 76 id: 'settings.app.currentVersion',
65 defaultMessage: '!!!Current version:', 77 defaultMessage: '!!!Current version:',
66 }, 78 },
67 restartRequired: {
68 id: 'settings.app.restartRequired',
69 defaultMessage: '!!!Changes require restart',
70 },
71}); 79});
72 80
73@observer 81@observer
@@ -81,6 +89,9 @@ export default class EditSettingsForm extends Component {
81 isUpdateAvailable: PropTypes.bool.isRequired, 89 isUpdateAvailable: PropTypes.bool.isRequired,
82 noUpdateAvailable: PropTypes.bool.isRequired, 90 noUpdateAvailable: PropTypes.bool.isRequired,
83 updateIsReadyToInstall: PropTypes.bool.isRequired, 91 updateIsReadyToInstall: PropTypes.bool.isRequired,
92 isClearingAllCache: PropTypes.bool.isRequired,
93 onClearAllCache: PropTypes.func.isRequired,
94 cacheSize: PropTypes.string.isRequired,
84 }; 95 };
85 96
86 static contextTypes = { 97 static contextTypes = {
@@ -107,6 +118,9 @@ export default class EditSettingsForm extends Component {
107 isUpdateAvailable, 118 isUpdateAvailable,
108 noUpdateAvailable, 119 noUpdateAvailable,
109 updateIsReadyToInstall, 120 updateIsReadyToInstall,
121 isClearingAllCache,
122 onClearAllCache,
123 cacheSize,
110 } = this.props; 124 } = this.props;
111 const { intl } = this.context; 125 const { intl } = this.context;
112 126
@@ -158,8 +172,26 @@ export default class EditSettingsForm extends Component {
158 {/* Advanced */} 172 {/* Advanced */}
159 <h2 id="advanced">{intl.formatMessage(messages.headlineAdvanced)}</h2> 173 <h2 id="advanced">{intl.formatMessage(messages.headlineAdvanced)}</h2>
160 <Toggle field={form.$('enableSpellchecking')} /> 174 <Toggle field={form.$('enableSpellchecking')} />
161 <p className="settings__help">{intl.formatMessage(messages.restartRequired)}</p>
162 {/* <Select field={form.$('spellcheckingLanguage')} /> */} 175 {/* <Select field={form.$('spellcheckingLanguage')} /> */}
176 <div className="settings__settings-group">
177 <h3>
178 {intl.formatMessage(messages.subheadlineCache)}
179 </h3>
180 <p>
181 {intl.formatMessage(messages.cacheInfo, {
182 size: cacheSize,
183 })}
184 </p>
185 <p>
186 <Button
187 buttonType="secondary"
188 label={intl.formatMessage(messages.buttonClearAllCache)}
189 onClick={onClearAllCache}
190 disabled={isClearingAllCache}
191 loaded={!isClearingAllCache}
192 />
193 </p>
194 </div>
163 195
164 {/* Updates */} 196 {/* Updates */}
165 <h2 id="updates">{intl.formatMessage(messages.headlineUpdates)}</h2> 197 <h2 id="updates">{intl.formatMessage(messages.headlineUpdates)}</h2>
@@ -170,6 +202,7 @@ export default class EditSettingsForm extends Component {
170 /> 202 />
171 ) : ( 203 ) : (
172 <Button 204 <Button
205 buttonType="secondary"
173 label={intl.formatMessage(updateButtonLabelMessage)} 206 label={intl.formatMessage(updateButtonLabelMessage)}
174 onClick={checkForUpdates} 207 onClick={checkForUpdates}
175 disabled={isCheckingForUpdates || isUpdateAvailable} 208 disabled={isCheckingForUpdates || isUpdateAvailable}
diff --git a/src/components/ui/ImageUpload.js b/src/components/ui/ImageUpload.js
new file mode 100644
index 000000000..81c3b8da6
--- /dev/null
+++ b/src/components/ui/ImageUpload.js
@@ -0,0 +1,108 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5// import Loader from 'react-loader';
6import classnames from 'classnames';
7import Dropzone from 'react-dropzone';
8
9@observer
10export default class ImageUpload extends Component {
11 static propTypes = {
12 field: PropTypes.instanceOf(Field).isRequired,
13 className: PropTypes.string,
14 multiple: PropTypes.bool,
15 textDelete: PropTypes.string.isRequired,
16 textUpload: PropTypes.string.isRequired,
17 };
18
19 static defaultProps = {
20 className: null,
21 multiple: false,
22 };
23
24 state = {
25 path: null,
26 }
27
28 onDrop(acceptedFiles) {
29 const { field } = this.props;
30
31 acceptedFiles.forEach((file) => {
32 this.setState({
33 path: file.path,
34 });
35 this.props.field.onDrop(file);
36 });
37
38 field.set('');
39 }
40
41 dropzoneRef = null;
42
43 render() {
44 const {
45 field,
46 className,
47 multiple,
48 textDelete,
49 textUpload,
50 } = this.props;
51
52 const cssClasses = classnames({
53 'image-upload__dropzone': true,
54 [`${className}`]: className,
55 });
56
57 return (
58 <div className="image-upload-wrapper">
59 <label className="franz-form__label" htmlFor="iconUpload">{field.label}</label>
60 <div className="image-upload">
61 {(field.value && field.value !== 'delete') || this.state.path ? (
62 <div>
63 <div
64 className="image-upload__preview"
65 style={({
66 backgroundImage: `url("${this.state.path || field.value}")`,
67 })}
68 />
69 <div className="image-upload__action">
70 <button
71 type="button"
72 onClick={() => {
73 if (field.value) {
74 field.set('delete');
75 } else {
76 this.setState({
77 path: null,
78 });
79 }
80 }}
81 >
82 <i className="mdi mdi-delete" />
83 <p>
84 {textDelete}
85 </p>
86 </button>
87 <div className="image-upload__action-background" />
88 </div>
89 </div>
90 ) : (
91 <Dropzone
92 ref={(node) => { this.dropzoneRef = node; }}
93 onDrop={this.onDrop.bind(this)}
94 className={cssClasses}
95 multiple={multiple}
96 accept="image/jpeg, image/png"
97 >
98 <i className="mdi mdi-file-image" />
99 <p>
100 {textUpload}
101 </p>
102 </Dropzone>
103 )}
104 </div>
105 </div>
106 );
107 }
108}
diff --git a/src/components/ui/SearchInput.js b/src/components/ui/SearchInput.js
index bca412cef..a94cde201 100644
--- a/src/components/ui/SearchInput.js
+++ b/src/components/ui/SearchInput.js
@@ -9,36 +9,46 @@ import { debounce } from 'lodash';
9export default class SearchInput extends Component { 9export default class SearchInput extends Component {
10 static propTypes = { 10 static propTypes = {
11 value: PropTypes.string, 11 value: PropTypes.string,
12 defaultValue: PropTypes.string, 12 placeholder: PropTypes.string,
13 className: PropTypes.string, 13 className: PropTypes.string,
14 onChange: PropTypes.func, 14 onChange: PropTypes.func,
15 onReset: PropTypes.func, 15 onReset: PropTypes.func,
16 name: PropTypes.string, 16 name: PropTypes.string,
17 throttle: PropTypes.bool, 17 throttle: PropTypes.bool,
18 throttleDelay: PropTypes.number, 18 throttleDelay: PropTypes.number,
19 autoFocus: PropTypes.bool,
19 }; 20 };
20 21
21 static defaultProps = { 22 static defaultProps = {
22 value: '', 23 value: '',
23 defaultValue: '', 24 placeholder: '',
24 className: '', 25 className: '',
25 name: uuidv1(), 26 name: uuidv1(),
26 throttle: false, 27 throttle: false,
27 throttleDelay: 250, 28 throttleDelay: 250,
28 onChange: () => null, 29 onChange: () => null,
29 onReset: () => null, 30 onReset: () => null,
31 autoFocus: false,
30 } 32 }
31 33
32 constructor(props) { 34 constructor(props) {
33 super(props); 35 super(props);
34 36
35 this.state = { 37 this.state = {
36 value: props.value || props.defaultValue, 38 value: props.value,
37 }; 39 };
38 40
39 this.throttledOnChange = debounce(this.throttledOnChange, this.props.throttleDelay); 41 this.throttledOnChange = debounce(this.throttledOnChange, this.props.throttleDelay);
40 } 42 }
41 43
44 componentDidMount() {
45 const { autoFocus } = this.props;
46
47 if (autoFocus) {
48 this.input.focus();
49 }
50 }
51
42 onChange(e) { 52 onChange(e) {
43 const { throttle, onChange } = this.props; 53 const { throttle, onChange } = this.props;
44 const { value } = e.target; 54 const { value } = e.target;
@@ -52,26 +62,6 @@ export default class SearchInput extends Component {
52 } 62 }
53 } 63 }
54 64
55 onClick() {
56 const { defaultValue } = this.props;
57 const { value } = this.state;
58
59 if (value === defaultValue) {
60 this.setState({ value: '' });
61 }
62
63 this.input.focus();
64 }
65
66 onBlur() {
67 const { defaultValue } = this.props;
68 const { value } = this.state;
69
70 if (value === '') {
71 this.setState({ value: defaultValue });
72 }
73 }
74
75 throttledOnChange(e) { 65 throttledOnChange(e) {
76 const { onChange } = this.props; 66 const { onChange } = this.props;
77 67
@@ -79,8 +69,8 @@ export default class SearchInput extends Component {
79 } 69 }
80 70
81 reset() { 71 reset() {
82 const { defaultValue, onReset } = this.props; 72 const { onReset } = this.props;
83 this.setState({ value: defaultValue }); 73 this.setState({ value: '' });
84 74
85 onReset(); 75 onReset();
86 } 76 }
@@ -88,7 +78,7 @@ export default class SearchInput extends Component {
88 input = null; 78 input = null;
89 79
90 render() { 80 render() {
91 const { className, name, defaultValue } = this.props; 81 const { className, name, placeholder } = this.props;
92 const { value } = this.state; 82 const { value } = this.state;
93 83
94 return ( 84 return (
@@ -101,18 +91,16 @@ export default class SearchInput extends Component {
101 <label 91 <label
102 htmlFor={name} 92 htmlFor={name}
103 className="mdi mdi-magnify" 93 className="mdi mdi-magnify"
104 onClick={() => this.onClick()}
105 /> 94 />
106 <input 95 <input
107 name={name} 96 name={name}
108 type="text" 97 type="text"
98 placeholder={placeholder}
109 value={value} 99 value={value}
110 onChange={e => this.onChange(e)} 100 onChange={e => this.onChange(e)}
111 onClick={() => this.onClick()}
112 onBlur={() => this.onBlur()}
113 ref={(ref) => { this.input = ref; }} 101 ref={(ref) => { this.input = ref; }}
114 /> 102 />
115 {value !== defaultValue && value.length > 0 && ( 103 {value.length > 0 && (
116 <span 104 <span
117 className="mdi mdi-close-circle-outline" 105 className="mdi mdi-close-circle-outline"
118 onClick={() => this.reset()} 106 onClick={() => this.reset()}
diff --git a/src/components/ui/SubscriptionPopup.js b/src/components/ui/SubscriptionPopup.js
index 5aae2c47a..528d02907 100644
--- a/src/components/ui/SubscriptionPopup.js
+++ b/src/components/ui/SubscriptionPopup.js
@@ -58,7 +58,6 @@ export default class SubscriptionPopup extends Component {
58 58
59 autosize 59 autosize
60 src={encodeURI(url)} 60 src={encodeURI(url)}
61 disablewebsecurity
62 onDidNavigate={completeCheck} 61 onDidNavigate={completeCheck}
63 // onNewWindow={(event, url, frameName, options) => 62 // onNewWindow={(event, url, frameName, options) =>
64 // openWindow({ event, url, frameName, options })} 63 // openWindow({ event, url, frameName, options })}
diff --git a/src/config.js b/src/config.js
index e6d8958e6..e66594c59 100644
--- a/src/config.js
+++ b/src/config.js
@@ -14,7 +14,8 @@ export const DEFAULT_APP_SETTINGS = {
14 showMessageBadgeWhenMuted: true, 14 showMessageBadgeWhenMuted: true,
15 enableSpellchecking: true, 15 enableSpellchecking: true,
16 // spellcheckingLanguage: 'auto', 16 // spellcheckingLanguage: 'auto',
17 locale: 'en-US', 17 locale: '',
18 fallbackLocale: 'en-US',
18 beta: false, 19 beta: false,
19 isAppMuted: false, 20 isAppMuted: false,
20}; 21};
diff --git a/src/containers/settings/EditServiceScreen.js b/src/containers/settings/EditServiceScreen.js
index 191ef447b..c26195a1e 100644
--- a/src/containers/settings/EditServiceScreen.js
+++ b/src/containers/settings/EditServiceScreen.js
@@ -26,6 +26,10 @@ const messages = defineMessages({
26 id: 'settings.service.form.enableNotification', 26 id: 'settings.service.form.enableNotification',
27 defaultMessage: '!!!Enable Notifications', 27 defaultMessage: '!!!Enable Notifications',
28 }, 28 },
29 enableBadge: {
30 id: 'settings.service.form.enableBadge',
31 defaultMessage: '!!!Show unread message badges',
32 },
29 enableAudio: { 33 enableAudio: {
30 id: 'settings.service.form.enableAudio', 34 id: 'settings.service.form.enableAudio',
31 defaultMessage: '!!!Enable audio', 35 defaultMessage: '!!!Enable audio',
@@ -42,6 +46,10 @@ const messages = defineMessages({
42 id: 'settings.service.form.indirectMessages', 46 id: 'settings.service.form.indirectMessages',
43 defaultMessage: '!!!Show message badge for all new messages', 47 defaultMessage: '!!!Show message badge for all new messages',
44 }, 48 },
49 icon: {
50 id: 'settings.service.form.icon',
51 defaultMessage: '!!!Custom icon',
52 },
45}); 53});
46 54
47@inject('stores', 'actions') @observer 55@inject('stores', 'actions') @observer
@@ -88,11 +96,22 @@ export default class EditServiceScreen extends Component {
88 value: service.isNotificationEnabled, 96 value: service.isNotificationEnabled,
89 default: true, 97 default: true,
90 }, 98 },
99 isBadgeEnabled: {
100 label: intl.formatMessage(messages.enableBadge),
101 value: service.isBadgeEnabled,
102 default: true,
103 },
91 isMuted: { 104 isMuted: {
92 label: intl.formatMessage(messages.enableAudio), 105 label: intl.formatMessage(messages.enableAudio),
93 value: !service.isMuted, 106 value: !service.isMuted,
94 default: true, 107 default: true,
95 }, 108 },
109 customIcon: {
110 label: intl.formatMessage(messages.icon),
111 value: service.hasCustomUploadedIcon ? service.icon : false,
112 default: null,
113 type: 'file',
114 },
96 }, 115 },
97 }; 116 };
98 117
@@ -118,11 +137,22 @@ export default class EditServiceScreen extends Component {
118 }); 137 });
119 } 138 }
120 139
140 // More fine grained and use case specific validation rules
121 if (recipe.hasTeamId && recipe.hasCustomUrl) { 141 if (recipe.hasTeamId && recipe.hasCustomUrl) {
122 config.fields.team.validate = [oneRequired(['team', 'customUrl'])]; 142 config.fields.team.validate = [oneRequired(['team', 'customUrl'])];
123 config.fields.customUrl.validate = [url, oneRequired(['team', 'customUrl'])]; 143 config.fields.customUrl.validate = [url, oneRequired(['team', 'customUrl'])];
124 } 144 }
125 145
146 // If a service can be hosted and has a teamId or customUrl
147 if (recipe.hasHostedOption && (recipe.hasTeamId || recipe.hasCustomUrl)) {
148 if (config.fields.team) {
149 config.fields.team.validate = [];
150 }
151 if (config.fields.customUrl) {
152 config.fields.customUrl.validate = [url];
153 }
154 }
155
126 if (recipe.hasIndirectMessages) { 156 if (recipe.hasIndirectMessages) {
127 Object.assign(config.fields, { 157 Object.assign(config.fields, {
128 isIndirectMessageBadgeEnabled: { 158 isIndirectMessageBadgeEnabled: {
@@ -179,6 +209,12 @@ export default class EditServiceScreen extends Component {
179 return (<div>Loading...</div>); 209 return (<div>Loading...</div>);
180 } 210 }
181 211
212 if (!recipe) {
213 return (
214 <div>something went wrong</div>
215 );
216 }
217
182 const form = this.prepareForm(recipe, service); 218 const form = this.prepareForm(recipe, service);
183 219
184 return ( 220 return (
diff --git a/src/containers/settings/EditSettingsScreen.js b/src/containers/settings/EditSettingsScreen.js
index 1a297f41f..1fa7ce8bc 100644
--- a/src/containers/settings/EditSettingsScreen.js
+++ b/src/containers/settings/EditSettingsScreen.js
@@ -193,8 +193,17 @@ export default class EditSettingsScreen extends Component {
193 } 193 }
194 194
195 render() { 195 render() {
196 const { updateStatus, updateStatusTypes } = this.props.stores.app; 196 const {
197 const { checkForUpdates, installUpdate } = this.props.actions.app; 197 updateStatus,
198 cacheSize,
199 updateStatusTypes,
200 isClearingAllCache,
201 } = this.props.stores.app;
202 const {
203 checkForUpdates,
204 installUpdate,
205 clearAllCache,
206 } = this.props.actions.app;
198 const form = this.prepareForm(); 207 const form = this.prepareForm();
199 208
200 return ( 209 return (
@@ -207,6 +216,9 @@ export default class EditSettingsScreen extends Component {
207 noUpdateAvailable={updateStatus === updateStatusTypes.NOT_AVAILABLE} 216 noUpdateAvailable={updateStatus === updateStatusTypes.NOT_AVAILABLE}
208 updateIsReadyToInstall={updateStatus === updateStatusTypes.DOWNLOADED} 217 updateIsReadyToInstall={updateStatus === updateStatusTypes.DOWNLOADED}
209 onSubmit={d => this.onSubmit(d)} 218 onSubmit={d => this.onSubmit(d)}
219 cacheSize={cacheSize}
220 isClearingAllCache={isClearingAllCache}
221 onClearAllCache={clearAllCache}
210 /> 222 />
211 ); 223 );
212 } 224 }
@@ -223,6 +235,7 @@ EditSettingsScreen.wrappedComponent.propTypes = {
223 launchOnStartup: PropTypes.func.isRequired, 235 launchOnStartup: PropTypes.func.isRequired,
224 checkForUpdates: PropTypes.func.isRequired, 236 checkForUpdates: PropTypes.func.isRequired,
225 installUpdate: PropTypes.func.isRequired, 237 installUpdate: PropTypes.func.isRequired,
238 clearAllCache: PropTypes.func.isRequired,
226 }).isRequired, 239 }).isRequired,
227 settings: PropTypes.shape({ 240 settings: PropTypes.shape({
228 update: PropTypes.func.isRequired, 241 update: PropTypes.func.isRequired,
diff --git a/src/containers/settings/ServicesScreen.js b/src/containers/settings/ServicesScreen.js
index 8cfe5efbf..12db1bcd3 100644
--- a/src/containers/settings/ServicesScreen.js
+++ b/src/containers/settings/ServicesScreen.js
@@ -53,6 +53,7 @@ export default class ServicesScreen extends Component {
53 goTo={router.push} 53 goTo={router.push}
54 servicesRequestFailed={services.allServicesRequest.wasExecuted && services.allServicesRequest.isError} 54 servicesRequestFailed={services.allServicesRequest.wasExecuted && services.allServicesRequest.isError}
55 retryServicesRequest={() => services.allServicesRequest.reload()} 55 retryServicesRequest={() => services.allServicesRequest.reload()}
56 searchNeedle={services.filterNeedle}
56 /> 57 />
57 ); 58 );
58 } 59 }
diff --git a/src/electron/deepLinking.js b/src/electron/deepLinking.js
new file mode 100644
index 000000000..ef23fd3c5
--- /dev/null
+++ b/src/electron/deepLinking.js
@@ -0,0 +1,7 @@
1export default function handleDeepLink(window, rawUrl) {
2 const url = rawUrl.replace('franz://', '');
3
4 if (!url) return;
5
6 window.webContents.send('navigateFromDeepLink', { url });
7}
diff --git a/src/electron/ipc-api/autoUpdate.js b/src/electron/ipc-api/autoUpdate.js
index 7bc193e2d..ba49a2f97 100644
--- a/src/electron/ipc-api/autoUpdate.js
+++ b/src/electron/ipc-api/autoUpdate.js
@@ -1,8 +1,9 @@
1import { app, ipcMain } from 'electron'; 1import { app, ipcMain } from 'electron';
2import { autoUpdater } from 'electron-updater'; 2import { autoUpdater } from 'electron-updater';
3import { isDevMode } from '../../environment.js';
3 4
4export default (params) => { 5export default (params) => {
5 if (process.platform === 'darwin' || process.platform === 'win32') { 6 if (!isDevMode && (process.platform === 'darwin' || process.platform === 'win32')) {
6 // autoUpdater.setFeedURL(updateUrl); 7 // autoUpdater.setFeedURL(updateUrl);
7 ipcMain.on('autoUpdate', (event, args) => { 8 ipcMain.on('autoUpdate', (event, args) => {
8 try { 9 try {
diff --git a/src/helpers/async-helpers.js b/src/helpers/async-helpers.js
new file mode 100644
index 000000000..2ef01ee09
--- /dev/null
+++ b/src/helpers/async-helpers.js
@@ -0,0 +1,5 @@
1/* eslint-disable import/prefer-default-export */
2
3export function sleep(ms = 0) {
4 return new Promise(r => setTimeout(r, ms));
5}
diff --git a/src/helpers/service-helpers.js b/src/helpers/service-helpers.js
new file mode 100644
index 000000000..5f63f6b7c
--- /dev/null
+++ b/src/helpers/service-helpers.js
@@ -0,0 +1,20 @@
1import path from 'path';
2import { remote } from 'electron';
3import fs from 'fs-extra';
4
5const app = remote.app;
6
7export function getServicePartitionsDirectory() {
8 return path.join(app.getPath('userData'), 'Partitions');
9}
10
11export function removeServicePartitionDirectory(id = '', addServicePrefix = false) {
12 const servicePartition = path.join(getServicePartitionsDirectory(), `${addServicePrefix ? 'service-' : ''}${id}`);
13
14 return fs.remove(servicePartition);
15}
16
17export async function getServiceIdsFromPartitions() {
18 const files = await fs.readdir(getServicePartitionsDirectory());
19 return files.filter(n => n !== '__chrome_extension');
20}
diff --git a/src/helpers/webview-ime-focus-helpers.js b/src/helpers/webview-ime-focus-helpers.js
deleted file mode 100644
index 2593a5f26..000000000
--- a/src/helpers/webview-ime-focus-helpers.js
+++ /dev/null
@@ -1,38 +0,0 @@
1module.exports.releaseDocumentFocus = () => {
2 const element = document.createElement('span');
3 document.body.appendChild(element);
4
5 const range = document.createRange();
6 range.setStart(element, 0);
7
8 const selection = window.getSelection();
9 selection.removeAllRanges();
10 selection.addRange(range);
11 selection.removeAllRanges();
12
13 document.body.removeChild(element);
14};
15
16module.exports.claimDocumentFocus = () => {
17 const { activeElement } = document;
18 const selection = window.getSelection();
19
20 let selectionStart;
21 let selectionEnd;
22 let range;
23
24 if (activeElement) ({ selectionStart, selectionEnd } = activeElement);
25 if (selection.rangeCount) range = selection.getRangeAt(0);
26
27 const restoreOriginalSelection = () => {
28 if (selectionStart >= 0 && selectionEnd >= 0) {
29 activeElement.selectionStart = selectionStart;
30 activeElement.selectionEnd = selectionEnd;
31 } else if (range) {
32 selection.addRange(range);
33 }
34 };
35
36 exports.releaseDocumentFocus();
37 window.requestAnimationFrame(restoreOriginalSelection);
38};
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 48b408e59..7fc9eac1c 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -66,6 +66,7 @@
66 "sidebar.unmuteApp": "Enable notifications & audio", 66 "sidebar.unmuteApp": "Enable notifications & audio",
67 "services.welcome": "Welcome to Franz", 67 "services.welcome": "Welcome to Franz",
68 "services.getStarted": "Get started", 68 "services.getStarted": "Get started",
69 "settings.searchService": "Search service",
69 "settings.account.headline": "Account", 70 "settings.account.headline": "Account",
70 "settings.account.headlineSubscription": "Your subscription", 71 "settings.account.headlineSubscription": "Your subscription",
71 "settings.account.headlineUpgrade": "Upgrade your account & support Franz", 72 "settings.account.headlineUpgrade": "Upgrade your account & support Franz",
@@ -110,6 +111,7 @@
110 "settings.service.form.editServiceHeadline": "Edit {name}", 111 "settings.service.form.editServiceHeadline": "Edit {name}",
111 "settings.service.form.tabHosted": "Hosted", 112 "settings.service.form.tabHosted": "Hosted",
112 "settings.service.form.tabOnPremise": "Self hosted ⭐️", 113 "settings.service.form.tabOnPremise": "Self hosted ⭐️",
114 "settings.service.form.useHostedService": "Use the hosted {name} service.",
113 "settings.service.form.customUrlValidationError": "Could not validate custom {name} server.", 115 "settings.service.form.customUrlValidationError": "Could not validate custom {name} server.",
114 "settings.service.form.customUrlPremiumInfo": "To add self hosted services, you need a Franz Premium Supporter Account.", 116 "settings.service.form.customUrlPremiumInfo": "To add self hosted services, you need a Franz Premium Supporter Account.",
115 "settings.service.form.customUrlUpgradeAccount": "Upgrade your account", 117 "settings.service.form.customUrlUpgradeAccount": "Upgrade your account",
@@ -117,11 +119,18 @@
117 "settings.service.form.name": "Name", 119 "settings.service.form.name": "Name",
118 "settings.service.form.enableService": "Enable service", 120 "settings.service.form.enableService": "Enable service",
119 "settings.service.form.enableNotification": "Enable notifications", 121 "settings.service.form.enableNotification": "Enable notifications",
122 "settings.service.form.enableBadge": "Show unread message badges",
120 "settings.service.form.team": "Team", 123 "settings.service.form.team": "Team",
121 "settings.service.form.customUrl": "Custom server", 124 "settings.service.form.customUrl": "Custom server",
122 "settings.service.form.indirectMessages": "Show message badge for all new messages", 125 "settings.service.form.indirectMessages": "Show message badge for all new messages",
123 "settings.service.form.enableAudio": "Enable audio", 126 "settings.service.form.enableAudio": "Enable audio",
124 "settings.service.form.isMutedInfo": "When disabled, all notification sounds and audio playback are muted", 127 "settings.service.form.isMutedInfo": "When disabled, all notification sounds and audio playback are muted",
128 "settings.service.form.headlineNotifications": "Notifications",
129 "settings.service.form.headlineBadges": "Unread message badges",
130 "settings.service.form.headlineGeneral": "General",
131 "settings.service.form.icon": "Custom icon",
132 "settings.service.form.iconDelete": "Delete",
133 "settings.service.form.iconUpload": "Drop your image, or click here",
125 "settings.service.error.headline": "Error", 134 "settings.service.error.headline": "Error",
126 "settings.service.error.goBack": "Back to services", 135 "settings.service.error.goBack": "Back to services",
127 "settings.service.error.message": "Could not load service recipe.", 136 "settings.service.error.message": "Could not load service recipe.",
@@ -144,6 +153,9 @@
144 "settings.app.updateStatusSearching": "Is searching for update", 153 "settings.app.updateStatusSearching": "Is searching for update",
145 "settings.app.updateStatusAvailable": "Update available, downloading...", 154 "settings.app.updateStatusAvailable": "Update available, downloading...",
146 "settings.app.updateStatusUpToDate": "You are using the latest version of Franz", 155 "settings.app.updateStatusUpToDate": "You are using the latest version of Franz",
156 "settings.app.subheadlineCache": "Cache",
157 "settings.app.cacheInfo": "Franz cache is currently using {size} of disk space.",
158 "settings.app.buttonClearAllCache": "Clear cache",
147 "settings.app.form.autoLaunchOnStart": "Launch Franz on start", 159 "settings.app.form.autoLaunchOnStart": "Launch Franz on start",
148 "settings.app.form.autoLaunchInBackground": "Open in background", 160 "settings.app.form.autoLaunchInBackground": "Open in background",
149 "settings.app.form.enableSystemTray": "Show Franz in system tray", 161 "settings.app.form.enableSystemTray": "Show Franz in system tray",
diff --git a/src/index.html b/src/index.html
index 05a93e37b..9e5acd705 100644
--- a/src/index.html
+++ b/src/index.html
@@ -23,6 +23,24 @@
23 s.async = true; 23 s.async = true;
24 s.setAttribute('src', lrHost + '/livereload.js'); 24 s.setAttribute('src', lrHost + '/livereload.js');
25 document.body.appendChild(s); 25 document.body.appendChild(s);
26
27 s.onload = () => {
28 console.log('livereload loaded');
29 const originalReloadBehaviour = window._onLiveReloadFileChanged;
30
31 window._onLiveReloadFileChanged = (file) => {
32 if (!file.path.includes('/build/webview/') && !file.path.includes('/build/index.js') && !file.path.includes('/build/electron/')) {
33 originalReloadBehaviour(file);
34 } else {
35 if (file.path.includes('/build/webview/')) {
36 console.log('Livereload: Reloading all webvies');
37 const webviews = document.querySelectorAll('webview').forEach(webview => webview.reload());
38 } else {
39 console.log('Livereload: skip reload as only main process files have changed');
40 }
41 }
42 }
43 }
26 })(); 44 })();
27 } 45 }
28 </script> 46 </script>
diff --git a/src/index.js b/src/index.js
index 6a08e5e5a..f82bb3590 100644
--- a/src/index.js
+++ b/src/index.js
@@ -8,6 +8,7 @@ import { isDevMode, isWindows } from './environment';
8import ipcApi from './electron/ipc-api'; 8import ipcApi from './electron/ipc-api';
9import Tray from './lib/Tray'; 9import Tray from './lib/Tray';
10import Settings from './electron/Settings'; 10import Settings from './electron/Settings';
11import handleDeepLink from './electron/deepLinking';
11import { appId } from './package.json'; // eslint-disable-line import/no-unresolved 12import { appId } from './package.json'; // eslint-disable-line import/no-unresolved
12import './electron/exception'; 13import './electron/exception';
13 14
@@ -26,14 +27,24 @@ if (isWindows) {
26} 27}
27 28
28// Force single window 29// Force single window
29const isSecondInstance = app.makeSingleInstance(() => { 30const isSecondInstance = app.makeSingleInstance((argv) => {
30 if (mainWindow) { 31 if (mainWindow) {
31 if (mainWindow.isMinimized()) mainWindow.restore(); 32 if (mainWindow.isMinimized()) mainWindow.restore();
32 mainWindow.focus(); 33 mainWindow.focus();
34
35 if (process.platform === 'win32') {
36 // Keep only command line / deep linked arguments
37 const url = argv.slice(1);
38
39 if (url) {
40 handleDeepLink(mainWindow, url.toString());
41 }
42 }
33 } 43 }
34}); 44});
35 45
36if (isSecondInstance) { 46if (isSecondInstance) {
47 console.log('An instance of Franz is already running. Exiting...');
37 app.exit(); 48 app.exit();
38} 49}
39 50
@@ -176,3 +187,15 @@ app.on('activate', () => {
176 mainWindow.show(); 187 mainWindow.show();
177 } 188 }
178}); 189});
190
191app.on('will-finish-launching', () => {
192 // Protocol handler for osx
193 app.on('open-url', (event, url) => {
194 event.preventDefault();
195 console.log(`open-url event: ${url}`);
196 handleDeepLink(mainWindow, url);
197 });
198});
199
200// Register App URL
201app.setAsDefaultProtocolClient('franz');
diff --git a/src/lib/Menu.js b/src/lib/Menu.js
index d9c30466b..d01666d49 100644
--- a/src/lib/Menu.js
+++ b/src/lib/Menu.js
@@ -167,7 +167,7 @@ export default class FranzMenu {
167 label: 'Settings', 167 label: 'Settings',
168 accelerator: 'CmdOrCtrl+,', 168 accelerator: 'CmdOrCtrl+,',
169 click: () => { 169 click: () => {
170 this.actions.ui.openSettings({ path: '' }); 170 this.actions.ui.openSettings({ path: 'app' });
171 }, 171 },
172 }, 172 },
173 { 173 {
diff --git a/src/models/Recipe.js b/src/models/Recipe.js
index 9971df77c..1fc23ac89 100644
--- a/src/models/Recipe.js
+++ b/src/models/Recipe.js
@@ -1,10 +1,11 @@
1import emailParser from 'address-rfc2822'; 1import emailParser from 'address-rfc2822';
2import semver from 'semver';
2 3
3export default class Recipe { 4export default class Recipe {
4 id = ''; 5 id = '';
5 name = ''; 6 name = '';
6 description = ''; 7 description = '';
7 version = '1.0'; 8 version = '';
8 path = ''; 9 path = '';
9 10
10 serviceURL = ''; 11 serviceURL = '';
@@ -15,6 +16,7 @@ export default class Recipe {
15 hasTeamId = false; 16 hasTeamId = false;
16 hasPredefinedUrl = false; 17 hasPredefinedUrl = false;
17 hasCustomUrl = false; 18 hasCustomUrl = false;
19 hasHostedOption = false;
18 urlInputPrefix = ''; 20 urlInputPrefix = '';
19 urlInputSuffix = ''; 21 urlInputSuffix = '';
20 22
@@ -30,6 +32,10 @@ export default class Recipe {
30 throw Error(`Recipe '${data.name}' requires Id`); 32 throw Error(`Recipe '${data.name}' requires Id`);
31 } 33 }
32 34
35 if (!semver.valid(data.version)) {
36 throw Error(`Version ${data.version} of recipe '${data.name}' is not a valid semver version`);
37 }
38
33 this.id = data.id || this.id; 39 this.id = data.id || this.id;
34 this.name = data.name || this.name; 40 this.name = data.name || this.name;
35 this.rawAuthor = data.author || this.author; 41 this.rawAuthor = data.author || this.author;
@@ -45,6 +51,7 @@ export default class Recipe {
45 this.hasTeamId = data.config.hasTeamId || this.hasTeamId; 51 this.hasTeamId = data.config.hasTeamId || this.hasTeamId;
46 this.hasPredefinedUrl = data.config.hasPredefinedUrl || this.hasPredefinedUrl; 52 this.hasPredefinedUrl = data.config.hasPredefinedUrl || this.hasPredefinedUrl;
47 this.hasCustomUrl = data.config.hasCustomUrl || this.hasCustomUrl; 53 this.hasCustomUrl = data.config.hasCustomUrl || this.hasCustomUrl;
54 this.hasHostedOption = data.config.hasHostedOption || this.hasHostedOption;
48 55
49 this.urlInputPrefix = data.config.urlInputPrefix || this.urlInputPrefix; 56 this.urlInputPrefix = data.config.urlInputPrefix || this.urlInputPrefix;
50 this.urlInputSuffix = data.config.urlInputSuffix || this.urlInputSuffix; 57 this.urlInputSuffix = data.config.urlInputSuffix || this.urlInputSuffix;
diff --git a/src/models/Service.js b/src/models/Service.js
index 958e4b11e..423510c7d 100644
--- a/src/models/Service.js
+++ b/src/models/Service.js
@@ -22,8 +22,10 @@ export default class Service {
22 @observable team = ''; 22 @observable team = '';
23 @observable customUrl = ''; 23 @observable customUrl = '';
24 @observable isNotificationEnabled = true; 24 @observable isNotificationEnabled = true;
25 @observable isBadgeEnabled = true;
25 @observable isIndirectMessageBadgeEnabled = true; 26 @observable isIndirectMessageBadgeEnabled = true;
26 @observable customIconUrl = ''; 27 @observable iconUrl = '';
28 @observable hasCustomUploadedIcon = false;
27 @observable hasCrashed = false; 29 @observable hasCrashed = false;
28 30
29 constructor(data, recipe) { 31 constructor(data, recipe) {
@@ -41,7 +43,8 @@ export default class Service {
41 this.name = data.name || this.name; 43 this.name = data.name || this.name;
42 this.team = data.team || this.team; 44 this.team = data.team || this.team;
43 this.customUrl = data.customUrl || this.customUrl; 45 this.customUrl = data.customUrl || this.customUrl;
44 this.customIconUrl = data.customIconUrl || this.customIconUrl; 46 // this.customIconUrl = data.customIconUrl || this.customIconUrl;
47 this.iconUrl = data.iconUrl || this.iconUrl;
45 48
46 this.order = data.order !== undefined 49 this.order = data.order !== undefined
47 ? data.order : this.order; 50 ? data.order : this.order;
@@ -52,11 +55,16 @@ export default class Service {
52 this.isNotificationEnabled = data.isNotificationEnabled !== undefined 55 this.isNotificationEnabled = data.isNotificationEnabled !== undefined
53 ? data.isNotificationEnabled : this.isNotificationEnabled; 56 ? data.isNotificationEnabled : this.isNotificationEnabled;
54 57
58 this.isBadgeEnabled = data.isBadgeEnabled !== undefined
59 ? data.isBadgeEnabled : this.isBadgeEnabled;
60
55 this.isIndirectMessageBadgeEnabled = data.isIndirectMessageBadgeEnabled !== undefined 61 this.isIndirectMessageBadgeEnabled = data.isIndirectMessageBadgeEnabled !== undefined
56 ? data.isIndirectMessageBadgeEnabled : this.isIndirectMessageBadgeEnabled; 62 ? data.isIndirectMessageBadgeEnabled : this.isIndirectMessageBadgeEnabled;
57 63
58 this.isMuted = data.isMuted !== undefined ? data.isMuted : this.isMuted; 64 this.isMuted = data.isMuted !== undefined ? data.isMuted : this.isMuted;
59 65
66 this.hasCustomUploadedIcon = data.hasCustomIcon !== undefined ? data.hasCustomIcon : this.hasCustomUploadedIcon;
67
60 this.recipe = recipe; 68 this.recipe = recipe;
61 69
62 autorun(() => { 70 autorun(() => {
@@ -93,15 +101,15 @@ export default class Service {
93 } 101 }
94 102
95 @computed get icon() { 103 @computed get icon() {
96 if (this.hasCustomIcon) { 104 if (this.iconUrl) {
97 return this.customIconUrl; 105 return this.iconUrl;
98 } 106 }
99 107
100 return path.join(this.recipe.path, 'icon.svg'); 108 return path.join(this.recipe.path, 'icon.svg');
101 } 109 }
102 110
103 @computed get hasCustomIcon() { 111 @computed get hasCustomIcon() {
104 return (this.customIconUrl !== ''); 112 return Boolean(this.iconUrl);
105 } 113 }
106 114
107 @computed get iconPNG() { 115 @computed get iconPNG() {
diff --git a/src/models/Settings.js b/src/models/Settings.js
index 35bfe0d05..ca44da258 100644
--- a/src/models/Settings.js
+++ b/src/models/Settings.js
@@ -1,4 +1,4 @@
1import { observable } from 'mobx'; 1import { observable, extendObservable } from 'mobx';
2import { DEFAULT_APP_SETTINGS } from '../config'; 2import { DEFAULT_APP_SETTINGS } from '../config';
3 3
4export default class Settings { 4export default class Settings {
@@ -17,4 +17,8 @@ export default class Settings {
17 constructor(data) { 17 constructor(data) {
18 Object.assign(this, data); 18 Object.assign(this, data);
19 } 19 }
20
21 update(data) {
22 extendObservable(this, data);
23 }
20} 24}
diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js
index 17ec832cf..e33f50f05 100644
--- a/src/stores/AppStore.js
+++ b/src/stores/AppStore.js
@@ -1,10 +1,11 @@
1import { remote, ipcRenderer, shell } from 'electron'; 1import { remote, ipcRenderer, shell } from 'electron';
2import { action, observable } from 'mobx'; 2import { action, computed, observable } from 'mobx';
3import moment from 'moment'; 3import moment from 'moment';
4import key from 'keymaster'; 4import key from 'keymaster';
5import { getDoNotDisturb } from '@meetfranz/electron-notification-state'; 5import { getDoNotDisturb } from '@meetfranz/electron-notification-state';
6import idleTimer from '@paulcbetts/system-idle-time'; 6import idleTimer from '@paulcbetts/system-idle-time';
7import AutoLaunch from 'auto-launch'; 7import AutoLaunch from 'auto-launch';
8import prettyBytes from 'pretty-bytes';
8 9
9import Store from './lib/Store'; 10import Store from './lib/Store';
10import Request from './lib/Request'; 11import Request from './lib/Request';
@@ -14,7 +15,10 @@ import locales from '../i18n/translations';
14import { gaEvent } from '../lib/analytics'; 15import { gaEvent } from '../lib/analytics';
15import Miner from '../lib/Miner'; 16import Miner from '../lib/Miner';
16 17
17const { app, powerMonitor } = remote; 18import { getServiceIdsFromPartitions, removeServicePartitionDirectory } from '../helpers/service-helpers.js';
19
20const { app } = remote;
21
18const defaultLocale = DEFAULT_APP_SETTINGS.locale; 22const defaultLocale = DEFAULT_APP_SETTINGS.locale;
19const autoLauncher = new AutoLaunch({ 23const autoLauncher = new AutoLaunch({
20 name: 'Franz', 24 name: 'Franz',
@@ -30,6 +34,8 @@ export default class AppStore extends Store {
30 }; 34 };
31 35
32 @observable healthCheckRequest = new Request(this.api.app, 'health'); 36 @observable healthCheckRequest = new Request(this.api.app, 'health');
37 @observable getAppCacheSizeRequest = new Request(this.api.local, 'getAppCacheSize');
38 @observable clearAppCacheRequest = new Request(this.api.local, 'clearAppCache');
33 39
34 @observable autoLaunchOnStart = true; 40 @observable autoLaunchOnStart = true;
35 41
@@ -47,6 +53,8 @@ export default class AppStore extends Store {
47 53
48 @observable isSystemMuteOverridden = false; 54 @observable isSystemMuteOverridden = false;
49 55
56 @observable isClearingAllCache = false;
57
50 constructor(...args) { 58 constructor(...args) {
51 super(...args); 59 super(...args);
52 60
@@ -61,6 +69,7 @@ export default class AppStore extends Store {
61 this.actions.app.healthCheck.listen(this._healthCheck.bind(this)); 69 this.actions.app.healthCheck.listen(this._healthCheck.bind(this));
62 this.actions.app.muteApp.listen(this._muteApp.bind(this)); 70 this.actions.app.muteApp.listen(this._muteApp.bind(this));
63 this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this)); 71 this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this));
72 this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this));
64 73
65 this.registerReactions([ 74 this.registerReactions([
66 this._offlineCheck.bind(this), 75 this._offlineCheck.bind(this),
@@ -116,15 +125,31 @@ export default class AppStore extends Store {
116 } 125 }
117 }); 126 });
118 127
128 // Handle deep linking (franz://)
129 ipcRenderer.on('navigateFromDeepLink', (event, data) => {
130 const { url } = data;
131 if (!url) return;
132
133 this.stores.router.push(data.url);
134 });
135
136 const TIMEOUT = 5000;
119 // Check system idle time every minute 137 // Check system idle time every minute
120 setInterval(() => { 138 setInterval(() => {
121 this.idleTime = idleTimer.getIdleTime(); 139 this.idleTime = idleTimer.getIdleTime();
122 }, 60000); 140 }, TIMEOUT);
123 141
124 // Reload all services after a healthy nap 142 // Reload all services after a healthy nap
125 powerMonitor.on('resume', () => { 143 // Alternative solution for powerMonitor as the resume event is not fired
126 setTimeout(window.location.reload, 5000); 144 // More information: https://github.com/electron/electron/issues/1615
127 }); 145 let lastTime = (new Date()).getTime();
146 setInterval(() => {
147 const currentTime = (new Date()).getTime();
148 if (currentTime > (lastTime + TIMEOUT + 2000)) {
149 this._reactivateServices();
150 }
151 lastTime = currentTime;
152 }, TIMEOUT);
128 153
129 // Set active the next service 154 // Set active the next service
130 key( 155 key(
@@ -149,6 +174,10 @@ export default class AppStore extends Store {
149 this._healthCheck(); 174 this._healthCheck();
150 } 175 }
151 176
177 @computed get cacheSize() {
178 return prettyBytes(this.getAppCacheSizeRequest.execute().result || 0);
179 }
180
152 // Actions 181 // Actions
153 @action _notify({ title, options, notificationId, serviceId = null }) { 182 @action _notify({ title, options, notificationId, serviceId = null }) {
154 if (this.stores.settings.all.isAppMuted) return; 183 if (this.stores.settings.all.isAppMuted) return;
@@ -239,6 +268,23 @@ export default class AppStore extends Store {
239 this._muteApp({ isMuted: !this.stores.settings.all.isAppMuted }); 268 this._muteApp({ isMuted: !this.stores.settings.all.isAppMuted });
240 } 269 }
241 270
271 @action async _clearAllCache() {
272 this.isClearingAllCache = true;
273 const clearAppCache = this.clearAppCacheRequest.execute();
274 const allServiceIds = await getServiceIdsFromPartitions();
275 const allOrphanedServiceIds = allServiceIds.filter(id => !this.stores.services.all.find(s => id.replace('service-', '') === s.id));
276
277 await Promise.all(allOrphanedServiceIds.map(id => removeServicePartitionDirectory(id)));
278
279 await Promise.all(this.stores.services.all.map(s => this.actions.service.clearCache({ serviceId: s.id })));
280
281 await clearAppCache._promise;
282
283 this.getAppCacheSizeRequest.execute();
284
285 this.isClearingAllCache = false;
286 }
287
242 // Reactions 288 // Reactions
243 _offlineCheck() { 289 _offlineCheck() {
244 if (!this.isOnline) { 290 if (!this.isOnline) {
@@ -255,8 +301,10 @@ export default class AppStore extends Store {
255 _setLocale() { 301 _setLocale() {
256 const locale = this.stores.settings.all.locale; 302 const locale = this.stores.settings.all.locale;
257 303
258 if (locale && locale !== this.locale) { 304 if (locale && Object.prototype.hasOwnProperty.call(locales, locale) && locale !== this.locale) {
259 this.locale = locale; 305 this.locale = locale;
306 } else if (!locale) {
307 this.locale = this._getDefaultLocale();
260 } 308 }
261 } 309 }
262 310
@@ -281,6 +329,10 @@ export default class AppStore extends Store {
281 locale = defaultLocale; 329 locale = defaultLocale;
282 } 330 }
283 331
332 if (!locale) {
333 locale = DEFAULT_APP_SETTINGS.fallbackLocale;
334 }
335
284 return locale; 336 return locale;
285 } 337 }
286 338
@@ -343,6 +395,16 @@ export default class AppStore extends Store {
343 return autoLauncher.isEnabled() || false; 395 return autoLauncher.isEnabled() || false;
344 } 396 }
345 397
398 _reactivateServices(retryCount = 0) {
399 if (!this.isOnline) {
400 console.debug('reactivateServices: computer is offline, trying again in 5s, retries:', retryCount);
401 setTimeout(() => this._reactivateServices(retryCount + 1), 5000);
402 } else {
403 console.debug('reactivateServices: reload all services');
404 this.actions.service.reloadAll();
405 }
406 }
407
346 _systemDND() { 408 _systemDND() {
347 const dnd = getDoNotDisturb(); 409 const dnd = getDoNotDisturb();
348 if (dnd === this.stores.settings.all.isAppMuted || !this.isSystemMuteOverriden) { 410 if (dnd === this.stores.settings.all.isAppMuted || !this.isSystemMuteOverriden) {
diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js
index b04aafd78..7300a76c8 100644
--- a/src/stores/ServicesStore.js
+++ b/src/stores/ServicesStore.js
@@ -16,6 +16,7 @@ export default class ServicesStore extends Store {
16 @observable updateServiceRequest = new Request(this.api.services, 'update'); 16 @observable updateServiceRequest = new Request(this.api.services, 'update');
17 @observable reorderServicesRequest = new Request(this.api.services, 'reorder'); 17 @observable reorderServicesRequest = new Request(this.api.services, 'reorder');
18 @observable deleteServiceRequest = new Request(this.api.services, 'delete'); 18 @observable deleteServiceRequest = new Request(this.api.services, 'delete');
19 @observable clearCacheRequest = new Request(this.api.services, 'clearCache');
19 20
20 @observable filterNeedle = null; 21 @observable filterNeedle = null;
21 22
@@ -31,6 +32,7 @@ export default class ServicesStore extends Store {
31 this.actions.service.createFromLegacyService.listen(this._createFromLegacyService.bind(this)); 32 this.actions.service.createFromLegacyService.listen(this._createFromLegacyService.bind(this));
32 this.actions.service.updateService.listen(this._updateService.bind(this)); 33 this.actions.service.updateService.listen(this._updateService.bind(this));
33 this.actions.service.deleteService.listen(this._deleteService.bind(this)); 34 this.actions.service.deleteService.listen(this._deleteService.bind(this));
35 this.actions.service.clearCache.listen(this._clearCache.bind(this));
34 this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this)); 36 this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this));
35 this.actions.service.focusService.listen(this._focusService.bind(this)); 37 this.actions.service.focusService.listen(this._focusService.bind(this));
36 this.actions.service.focusActiveService.listen(this._focusActiveService.bind(this)); 38 this.actions.service.focusActiveService.listen(this._focusActiveService.bind(this));
@@ -172,9 +174,29 @@ export default class ServicesStore extends Store {
172 const data = this._cleanUpTeamIdAndCustomUrl(service.recipe.id, serviceData); 174 const data = this._cleanUpTeamIdAndCustomUrl(service.recipe.id, serviceData);
173 const request = this.updateServiceRequest.execute(serviceId, data); 175 const request = this.updateServiceRequest.execute(serviceId, data);
174 176
177 const newData = serviceData;
178 if (serviceData.iconFile) {
179 await request._promise;
180
181 newData.iconUrl = request.result.data.iconUrl;
182 newData.hasCustomUploadedIcon = true;
183 }
184
175 this.allServicesRequest.patch((result) => { 185 this.allServicesRequest.patch((result) => {
176 if (!result) return; 186 if (!result) return;
177 Object.assign(result.find(c => c.id === serviceId), serviceData); 187
188 // patch custom icon deletion
189 if (data.customIcon === 'delete') {
190 data.iconUrl = '';
191 data.hasCustomUploadedIcon = false;
192 }
193
194 // patch custom icon url
195 if (data.customIconUrl) {
196 data.iconUrl = data.customIconUrl;
197 }
198
199 Object.assign(result.find(c => c.id === serviceId), newData);
178 }); 200 });
179 201
180 await request._promise; 202 await request._promise;
@@ -205,6 +227,13 @@ export default class ServicesStore extends Store {
205 gaEvent('Service', 'delete', service.recipe.id); 227 gaEvent('Service', 'delete', service.recipe.id);
206 } 228 }
207 229
230 @action async _clearCache({ serviceId }) {
231 this.clearCacheRequest.reset();
232 const request = this.clearCacheRequest.execute(serviceId);
233 await request._promise;
234 gaEvent('Service', 'clear cache');
235 }
236
208 @action _setActive({ serviceId }) { 237 @action _setActive({ serviceId }) {
209 const service = this.one(serviceId); 238 const service = this.one(serviceId);
210 239
@@ -297,7 +326,7 @@ export default class ServicesStore extends Store {
297 }); 326 });
298 } else if (channel === 'notification') { 327 } else if (channel === 'notification') {
299 const options = args[0].options; 328 const options = args[0].options;
300 if (service.recipe.hasNotificationSound || service.isMuted) { 329 if (service.recipe.hasNotificationSound || service.isMuted || this.stores.settings.all.isAppMuted) {
301 Object.assign(options, { 330 Object.assign(options, {
302 silent: true, 331 silent: true,
303 }); 332 });
@@ -316,7 +345,7 @@ export default class ServicesStore extends Store {
316 } 345 }
317 } else if (channel === 'avatar') { 346 } else if (channel === 'avatar') {
318 const url = args[0]; 347 const url = args[0];
319 if (service.customIconUrl !== url) { 348 if (service.iconUrl !== url && !service.hasCustomUploadedIcon) {
320 service.customIconUrl = url; 349 service.customIconUrl = url;
321 350
322 this.actions.service.updateService({ 351 this.actions.service.updateService({
@@ -327,6 +356,10 @@ export default class ServicesStore extends Store {
327 redirect: false, 356 redirect: false,
328 }); 357 });
329 } 358 }
359 } else if (channel === 'new-window') {
360 const url = args[0];
361
362 this.actions.app.openExternalUrl({ url });
330 } 363 }
331 } 364 }
332 365
@@ -368,7 +401,7 @@ export default class ServicesStore extends Store {
368 const service = this.one(serviceId); 401 const service = this.one(serviceId);
369 service.resetMessageCount(); 402 service.resetMessageCount();
370 403
371 service.webview.reload(); 404 service.webview.loadURL(service.url);
372 } 405 }
373 406
374 @action _reloadActive() { 407 @action _reloadActive() {
@@ -491,18 +524,19 @@ export default class ServicesStore extends Store {
491 const showMessageBadgeWhenMuted = this.stores.settings.all.showMessageBadgeWhenMuted; 524 const showMessageBadgeWhenMuted = this.stores.settings.all.showMessageBadgeWhenMuted;
492 const showMessageBadgesEvenWhenMuted = this.stores.ui.showMessageBadgesEvenWhenMuted; 525 const showMessageBadgesEvenWhenMuted = this.stores.ui.showMessageBadgesEvenWhenMuted;
493 526
494 const unreadDirectMessageCount = this.enabled 527 const unreadDirectMessageCount = this.allDisplayed
495 .filter(s => (showMessageBadgeWhenMuted || s.isNotificationEnabled) && showMessageBadgesEvenWhenMuted) 528 .filter(s => (showMessageBadgeWhenMuted || s.isNotificationEnabled) && showMessageBadgesEvenWhenMuted && s.isBadgeEnabled)
496 .map(s => s.unreadDirectMessageCount) 529 .map(s => s.unreadDirectMessageCount)
497 .reduce((a, b) => a + b, 0); 530 .reduce((a, b) => a + b, 0);
498 531
499 const unreadIndirectMessageCount = this.enabled 532 const unreadIndirectMessageCount = this.allDisplayed
500 .filter(s => (showMessageBadgeWhenMuted || s.isIndirectMessageBadgeEnabled) && showMessageBadgesEvenWhenMuted) 533 .filter(s => (showMessageBadgeWhenMuted && showMessageBadgesEvenWhenMuted) && (s.isBadgeEnabled && s.isIndirectMessageBadgeEnabled))
501 .map(s => s.unreadIndirectMessageCount) 534 .map(s => s.unreadIndirectMessageCount)
502 .reduce((a, b) => a + b, 0); 535 .reduce((a, b) => a + b, 0);
503 536
504 // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases 537 // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases
505 if (showMessageBadgesEvenWhenMuted) { 538 if (showMessageBadgesEvenWhenMuted) {
539 console.log('set badge', unreadDirectMessageCount, unreadIndirectMessageCount);
506 this.actions.app.setBadge({ 540 this.actions.app.setBadge({
507 unreadDirectMessageCount, 541 unreadDirectMessageCount,
508 unreadIndirectMessageCount, 542 unreadIndirectMessageCount,
diff --git a/src/stores/SettingsStore.js b/src/stores/SettingsStore.js
index 33473f16d..da99a720f 100644
--- a/src/stores/SettingsStore.js
+++ b/src/stores/SettingsStore.js
@@ -26,7 +26,7 @@ export default class SettingsStore extends Store {
26 } 26 }
27 27
28 @computed get all() { 28 @computed get all() {
29 return this.allSettingsRequest.result || new SettingsModel(); 29 return new SettingsModel(this.allSettingsRequest.result);
30 } 30 }
31 31
32 @action async _update({ settings }) { 32 @action async _update({ settings }) {
diff --git a/src/styles/button.scss b/src/styles/button.scss
index 75d2cb1d4..8d2adbbcc 100644
--- a/src/styles/button.scss
+++ b/src/styles/button.scss
@@ -48,6 +48,18 @@
48 } 48 }
49 } 49 }
50 50
51 &.franz-form__button--warning {
52 background: $theme-brand-warning;
53
54 &:hover {
55 background: darken($theme-brand-warning, 5%);
56 }
57
58 &:active {
59 background: lighten($theme-brand-warning, 5%);
60 }
61 }
62
51 &.franz-form__button--inverted { 63 &.franz-form__button--inverted {
52 background: none; 64 background: none;
53 padding: 10px 20px; 65 padding: 10px 20px;
diff --git a/src/styles/content-tabs.scss b/src/styles/content-tabs.scss
index aa3c8594b..47dfea2c4 100644
--- a/src/styles/content-tabs.scss
+++ b/src/styles/content-tabs.scss
@@ -12,15 +12,17 @@
12 flex: 1; 12 flex: 1;
13 // border: 1px solid $theme-gray-lightest; 13 // border: 1px solid $theme-gray-lightest;
14 color: $theme-gray-dark; 14 color: $theme-gray-dark;
15 background: $theme-gray-lightest; 15 background: linear-gradient($theme-gray-lightest 80%, darken($theme-gray-lightest, 3%));
16 border-bottom: 1px solid $theme-gray-lighter; 16 border-right: 1px solid $theme-gray-lighter;
17 box-shadow: inset 0px -3px 10px rgba(black, 0.05); 17 transition: background $theme-transition-time;
18 transition: all $theme-transition-time; 18
19 &:last-of-type {
20 border-right: 0;
21 }
19 22
20 &.is-active { 23 &.is-active {
21 background: $theme-brand-primary; 24 background: $theme-brand-primary;
22 color: #FFF; 25 color: #FFF;
23 border-bottom: 1px solid $theme-brand-primary;
24 box-shadow: none; 26 box-shadow: none;
25 } 27 }
26 } 28 }
diff --git a/src/styles/image-upload.scss b/src/styles/image-upload.scss
new file mode 100644
index 000000000..06176a7af
--- /dev/null
+++ b/src/styles/image-upload.scss
@@ -0,0 +1,91 @@
1.image-upload {
2 position: absolute;
3 width: 140px;
4 height: 140px;
5 border: 1px solid $theme-gray-lighter;
6 border-radius: $theme-border-radius-small;
7 background: $theme-gray-lightest;
8 overflow: hidden;
9 margin-top: 5px;
10
11 &__preview,
12 &__action {
13 position: absolute;
14 top: 0;
15 left: 0;
16 right: 0;
17 }
18
19 &__preview {
20 z-index: 1;
21 background-size: cover;
22 background-size: 100%;
23 background-repeat: no-repeat;
24 background-position: center center;
25 border-radius: 3px;
26 }
27
28 &__action {
29 position: relative;
30 z-index: 10;
31 opacity: 0;
32 transition: opacity 0.5s;
33 display: flex;
34 justify-content: center;
35
36 &-background {
37 position: absolute;
38 top: 0;
39 left: 0;
40 right: 0;
41 bottom: 0;
42 background: rgba($theme-gray, 0.7);
43 z-index: 10;
44 }
45
46 button {
47 position: relative;
48 z-index: 100;
49 color: #FFF;
50
51 .mdi {
52 color: #FFF;
53 }
54 }
55 }
56
57 &__dropzone {
58 text-align: center;
59 border-radius: 5px;
60 padding: 10px;
61 display: flex;
62 align-items: center;
63 justify-content: center;
64 flex-direction: column;
65 }
66
67 &__dropzone,
68 button {
69 .mdi {
70 margin-bottom: 5px;
71 }
72
73 p {
74 font-size: 10px;
75 line-height: 10px;
76 }
77 }
78
79 &:hover {
80 .image-upload__action {
81 opacity: 1;
82 }
83 }
84}
85
86.image-upload-wrapper {
87 .mdi {
88 font-size: 40px;
89 color: $theme-gray-light;
90 }
91} \ No newline at end of file
diff --git a/src/styles/input.scss b/src/styles/input.scss
index 814dce5f8..7042f56e8 100644
--- a/src/styles/input.scss
+++ b/src/styles/input.scss
@@ -47,6 +47,10 @@
47 padding: 8px; 47 padding: 8px;
48 // font-size: 18px; 48 // font-size: 18px;
49 color: $theme-gray; 49 color: $theme-gray;
50
51 &::placeholder {
52 color: lighten($theme-gray-light, 10%);
53 }
50 } 54 }
51 55
52 .franz-form__input-prefix, 56 .franz-form__input-prefix,
diff --git a/src/styles/main.scss b/src/styles/main.scss
index 0a082729c..261396f6f 100644
--- a/src/styles/main.scss
+++ b/src/styles/main.scss
@@ -35,3 +35,4 @@ $mdi-font-path: '../node_modules/mdi/fonts';
35@import './button.scss'; 35@import './button.scss';
36@import './searchInput.scss'; 36@import './searchInput.scss';
37@import './select.scss'; 37@import './select.scss';
38@import './image-upload.scss';
diff --git a/src/styles/searchInput.scss b/src/styles/searchInput.scss
index 28ff09fc4..633a31e09 100644
--- a/src/styles/searchInput.scss
+++ b/src/styles/searchInput.scss
@@ -1,4 +1,20 @@
1.search-input { 1.search-input {
2 width: 100%; 2 width: 100%;
3 height: auto; 3 height: auto;
4 display: flex;
5 align-items: center;
6 padding: 0 10px;
7 border-radius: 30px;
8 background: $theme-gray-lightest;
9 padding: 5px 10px;
10 @extend %headline;
11 color: $theme-gray-light;
12
13 input {
14 padding-left: 10px;
15 background: none;
16 border: 0;
17 flex: 1;
18 color: $theme-gray-light;
19 }
4} 20}
diff --git a/src/styles/services.scss b/src/styles/services.scss
index 282c15121..9f6cfc772 100644
--- a/src/styles/services.scss
+++ b/src/styles/services.scss
@@ -24,6 +24,7 @@
24 display: inline-flex; 24 display: inline-flex;
25 width: 0px; 25 width: 0px;
26 height: 0px; 26 height: 0px;
27 background: $theme-gray-lighter;
27 } 28 }
28 29
29 &.is-active { 30 &.is-active {
diff --git a/src/styles/settings.scss b/src/styles/settings.scss
index 73cef0813..2182c9b5f 100644
--- a/src/styles/settings.scss
+++ b/src/styles/settings.scss
@@ -111,6 +111,26 @@
111 &::-webkit-scrollbar-thumb:window-inactive { 111 &::-webkit-scrollbar-thumb:window-inactive {
112 background: none; 112 background: none;
113 } 113 }
114
115 .service-flex-grid {
116 display: flex;
117 }
118
119 .service-name {
120 flex: 1px;
121 }
122
123 .service-icon {
124 width: 140px;
125 float: right;
126 margin-top: 30px;
127 margin-left: 40px;
128
129 label {
130 font-weight: bold;
131 letter-spacing: -0.1px;
132 }
133 }
114 } 134 }
115 135
116 .settings__close { 136 .settings__close {
@@ -129,30 +149,28 @@
129 } 149 }
130 } 150 }
131 151
132 .settings__search-header { 152 .search-input {
133 display: flex; 153 margin-bottom: 30px;
134 align-items: center; 154 }
135 padding: 0 10px;
136 border-radius: $theme-border-radius;
137 transition: background $theme-transition-time;
138 @extend %headline;
139 font-size: 22px;
140
141 &:hover {
142 background: darken($theme-gray-lighter, 5%);
143 }
144 155
145 input { 156 &__options {
146 padding-left: 10px; 157 margin-top: 20px;
147 background: none; 158 flex: 1;
148 border: 0;
149 flex: 1;
150 @extend %headline;
151 }
152 } 159 }
153 160
154 .settings__options { 161 &__settings-group {
155 margin-top: 30px; 162 margin-top: 10px;
163
164 h3 {
165 font-weight: bold;
166 margin: 25px 0 15px;
167 color: $theme-gray-light;
168 letter-spacing: -0.1px;
169
170 &:first-of-type {
171 margin-top: 0;
172 }
173 }
156 } 174 }
157 175
158 .settings__message { 176 .settings__message {
@@ -173,10 +191,6 @@
173 margin: -10px 0 20px 55px;; 191 margin: -10px 0 20px 55px;;
174 font-size: 12px; 192 font-size: 12px;
175 color: $theme-gray-light; 193 color: $theme-gray-light;
176
177 &:last-of-type {
178 margin-bottom: 30px;
179 }
180 } 194 }
181 195
182 .settings__controls { 196 .settings__controls {
@@ -312,12 +326,6 @@
312 } 326 }
313 } 327 }
314 328
315 // @include element(add-service-teaser) {
316 // height: auto;
317 // margin-top: 20px;
318 // display: block;
319 // text-align: center;
320 // }
321 .emoji { 329 .emoji {
322 display: block; 330 display: block;
323 font-size: 40px; 331 font-size: 40px;
diff --git a/src/styles/welcome.scss b/src/styles/welcome.scss
index cfdcc80ad..46299b966 100644
--- a/src/styles/welcome.scss
+++ b/src/styles/welcome.scss
@@ -73,15 +73,8 @@
73 width: 35px; 73 width: 35px;
74 height: 35px; 74 height: 35px;
75 margin: 0 10px 15px; 75 margin: 0 10px 15px;
76 filter: grayscale(1)
77 opacity(0.5);
78 transition: 0.5s filter, 0.5s opacity; 76 transition: 0.5s filter, 0.5s opacity;
79 77
80 &:hover {
81 filter: grayscale(0);
82 opacity: (1);
83 }
84
85 img { 78 img {
86 width: 35px; 79 width: 35px;
87 } 80 }
diff --git a/src/webview/ime.js b/src/webview/ime.js
deleted file mode 100644
index 43df6267c..000000000
--- a/src/webview/ime.js
+++ /dev/null
@@ -1,10 +0,0 @@
1const { ipcRenderer } = require('electron');
2const { claimDocumentFocus } = require('../helpers/webview-ime-focus-helpers');
3
4ipcRenderer.on('claim-document-focus', claimDocumentFocus);
5
6window.addEventListener('DOMContentLoaded', () => {
7 if (document.querySelector('[autofocus]')) {
8 ipcRenderer.sendToHost('autofocus');
9 }
10});
diff --git a/src/webview/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js
index 048beea69..be29142af 100644
--- a/src/webview/lib/RecipeWebview.js
+++ b/src/webview/lib/RecipeWebview.js
@@ -40,8 +40,8 @@ class RecipeWebview {
40 && this.countCache.indirect === indirect) return; 40 && this.countCache.indirect === indirect) return;
41 41
42 const count = { 42 const count = {
43 direct, 43 direct: direct > 0 ? direct : 0,
44 indirect, 44 indirect: indirect > 0 ? indirect : 0,
45 }; 45 };
46 46
47 ipcRenderer.sendToHost('messages', count); 47 ipcRenderer.sendToHost('messages', count);
diff --git a/src/webview/notifications.js b/src/webview/notifications.js
index 4f602bfdb..2020bbdc6 100644
--- a/src/webview/notifications.js
+++ b/src/webview/notifications.js
@@ -16,7 +16,9 @@ class Notification {
16 })); 16 }));
17 17
18 ipcRenderer.once(`notification-onclick:${this.notificationId}`, () => { 18 ipcRenderer.once(`notification-onclick:${this.notificationId}`, () => {
19 this.onclick(); 19 if (typeof this.onclick === 'function') {
20 this.onclick();
21 }
20 }); 22 });
21 } 23 }
22 24
diff --git a/src/webview/plugin.js b/src/webview/plugin.js
index c877132b1..d9e021e6d 100644
--- a/src/webview/plugin.js
+++ b/src/webview/plugin.js
@@ -1,13 +1,12 @@
1import { ipcRenderer } from 'electron'; 1import { ipcRenderer } from 'electron';
2import { ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker';
2import path from 'path'; 3import path from 'path';
3 4
5import { isDevMode } from '../environment';
4import RecipeWebview from './lib/RecipeWebview'; 6import RecipeWebview from './lib/RecipeWebview';
5 7
6import Spellchecker from './spellchecker.js'; 8import Spellchecker from './spellchecker';
7import './notifications.js'; 9import './notifications';
8import './ime.js';
9
10const spellchecker = new Spellchecker();
11 10
12ipcRenderer.on('initializeRecipe', (e, data) => { 11ipcRenderer.on('initializeRecipe', (e, data) => {
13 const modulePath = path.join(data.recipe.path, 'webview.js'); 12 const modulePath = path.join(data.recipe.path, 'webview.js');
@@ -21,20 +20,34 @@ ipcRenderer.on('initializeRecipe', (e, data) => {
21 } 20 }
22}); 21});
23 22
23const spellchecker = new Spellchecker();
24spellchecker.initialize();
25
26const contextMenuBuilder = new ContextMenuBuilder(spellchecker.handler, null, isDevMode);
27
28new ContextMenuListener((info) => { // eslint-disable-line
29 contextMenuBuilder.showPopupMenu(info);
30});
31
24ipcRenderer.on('settings-update', (e, data) => { 32ipcRenderer.on('settings-update', (e, data) => {
25 if (data.enableSpellchecking) { 33 console.log('settings-update', data);
26 if (!spellchecker.isEnabled) { 34 spellchecker.toggleSpellchecker(data.enableSpellchecking);
27 spellchecker.enable();
28
29 // TODO: this does not work yet, needs more testing
30 // if (data.spellcheckingLanguage !== 'auto') {
31 // console.log('set spellchecking language to', data.spellcheckingLanguage);
32 // spellchecker.switchLanguage(data.spellcheckingLanguage);
33 // }
34 }
35 }
36}); 35});
37 36
37// initSpellche
38
38document.addEventListener('DOMContentLoaded', () => { 39document.addEventListener('DOMContentLoaded', () => {
39 ipcRenderer.sendToHost('hello'); 40 ipcRenderer.sendToHost('hello');
40}, false); 41}, false);
42
43// Patching window.open
44const originalWindowOpen = window.open;
45
46window.open = (url, frameName, features) => {
47 // We need to differentiate if the link should be opened in a popup or in the systems default browser
48 if (!frameName && !features) {
49 return ipcRenderer.sendToHost('new-window', url);
50 }
51
52 return originalWindowOpen(url, frameName, features);
53};
diff --git a/src/webview/spellchecker.js b/src/webview/spellchecker.js
index 5beb77e03..a504a4039 100644
--- a/src/webview/spellchecker.js
+++ b/src/webview/spellchecker.js
@@ -1,30 +1,63 @@
1import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker'; 1import { SpellCheckHandler } from 'electron-spellchecker';
2 2
3import { isMac } from '../environment'; 3import { isMac } from '../environment';
4 4
5export default class Spellchecker { 5export default class Spellchecker {
6 isEnabled = false; 6 isInitialized = false;
7 spellchecker = null; 7 handler = null;
8 initRetries = 0;
9 DOMCheckInterval = null;
10
11 get inputs() {
12 return document.querySelectorAll('input[type="text"], [contenteditable="true"], textarea');
13 }
14
15 initialize() {
16 this.handler = new SpellCheckHandler();
8 17
9 enable() {
10 this.spellchecker = new SpellCheckHandler();
11 if (!isMac) { 18 if (!isMac) {
12 this.spellchecker.attachToInput(); 19 this.attach();
13 this.spellchecker.switchLanguage(navigator.language); 20 } else {
21 this.isInitialized = true;
22 }
23 }
24
25 attach() {
26 let initFailed = false;
27
28 if (this.initRetries > 3) {
29 console.error('Could not initialize spellchecker');
30 return;
14 } 31 }
15 32
16 const contextMenuBuilder = new ContextMenuBuilder(this.spellchecker); 33 try {
34 this.handler.attachToInput();
35 this.handler.switchLanguage(navigator.language);
36 } catch (err) {
37 initFailed = true;
38 this.initRetries = +1;
39 setTimeout(() => { this.attach(); console.warn('Spellchecker init failed, trying again in 5s'); }, 5000);
40 }
17 41
18 new ContextMenuListener((info) => { // eslint-disable-line 42 if (!initFailed) {
19 contextMenuBuilder.showPopupMenu(info); 43 this.isInitialized = true;
44 }
45 }
46
47 toggleSpellchecker(enable = false) {
48 this.inputs.forEach((input) => {
49 input.setAttribute('spellcheck', enable);
20 }); 50 });
51
52 this.intervalHandler(enable);
21 } 53 }
22 54
23 // TODO: this does not work yet, needs more testing 55 intervalHandler(enable) {
24 // switchLanguage(language) { 56 clearInterval(this.DOMCheckInterval);
25 // if (language !== 'auto') { 57
26 // this.spellchecker.switchLanguage(language); 58 if (enable) {
27 // } 59 this.DOMCheckInterval = setInterval(() => this.toggleSpellchecker(enable), 30000);
28 // } 60 }
61 }
29} 62}
30 63
diff --git a/yarn.lock b/yarn.lock
index c662ce63f..58befebf1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -39,7 +39,7 @@
39 rimraf "^2.4.0" 39 rimraf "^2.4.0"
40 underscore "^1.6.0" 40 underscore "^1.6.0"
41 41
42"@paulcbetts/spellchecker@^4.0.5": 42"@paulcbetts/spellchecker@^4.0.6":
43 version "4.0.6" 43 version "4.0.6"
44 resolved "https://registry.yarnpkg.com/@paulcbetts/spellchecker/-/spellchecker-4.0.6.tgz#79ef1f9c19c5a3156921ccaa9ffdc3efbbee47e3" 44 resolved "https://registry.yarnpkg.com/@paulcbetts/spellchecker/-/spellchecker-4.0.6.tgz#79ef1f9c19c5a3156921ccaa9ffdc3efbbee47e3"
45 dependencies: 45 dependencies:
@@ -333,10 +333,18 @@ async@^0.9.0:
333 version "0.9.2" 333 version "0.9.2"
334 resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" 334 resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
335 335
336async@~0.1.22:
337 version "0.1.22"
338 resolved "https://registry.yarnpkg.com/async/-/async-0.1.22.tgz#0fc1aaa088a0e3ef0ebe2d8831bab0dcf8845061"
339
336asynckit@^0.4.0: 340asynckit@^0.4.0:
337 version "0.4.0" 341 version "0.4.0"
338 resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 342 resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
339 343
344attr-accept@^1.0.3:
345 version "1.1.0"
346 resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.0.tgz#b5cd35227f163935a8f1de10ed3eba16941f6be6"
347
340"auto-launch@https://github.com/meetfranz/node-auto-launch.git": 348"auto-launch@https://github.com/meetfranz/node-auto-launch.git":
341 version "5.0.1" 349 version "5.0.1"
342 resolved "https://github.com/meetfranz/node-auto-launch.git#b90a0470467eb84435e6554ae9db1e2c6db79e61" 350 resolved "https://github.com/meetfranz/node-auto-launch.git#b90a0470467eb84435e6554ae9db1e2c6db79e61"
@@ -1303,6 +1311,10 @@ capture-stack-trace@^1.0.0:
1303 version "1.0.0" 1311 version "1.0.0"
1304 resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d" 1312 resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d"
1305 1313
1314caseless@~0.11.0:
1315 version "0.11.0"
1316 resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7"
1317
1306caseless@~0.12.0: 1318caseless@~0.12.0:
1307 version "0.12.0" 1319 version "0.12.0"
1308 resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" 1320 resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -1779,6 +1791,12 @@ dotenv@^4.0.0:
1779 version "4.0.0" 1791 version "4.0.0"
1780 resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" 1792 resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
1781 1793
1794du@^0.1.0:
1795 version "0.1.0"
1796 resolved "https://registry.yarnpkg.com/du/-/du-0.1.0.tgz#f26e340a09c7bc5b6fd69af6dbadea60fa8c6f4d"
1797 dependencies:
1798 async "~0.1.22"
1799
1782duplexer2@0.0.2, duplexer2@~0.0.2: 1800duplexer2@0.0.2, duplexer2@~0.0.2:
1783 version "0.0.2" 1801 version "0.0.2"
1784 resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db" 1802 resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db"
@@ -2013,12 +2031,12 @@ electron-remote@^1.1.1:
2013 rxjs "^5.0.0-beta.12" 2031 rxjs "^5.0.0-beta.12"
2014 xmlhttprequest "^1.8.0" 2032 xmlhttprequest "^1.8.0"
2015 2033
2016electron-spellchecker@^1.2.0: 2034electron-spellchecker@^1.1.2:
2017 version "1.2.0" 2035 version "1.1.2"
2018 resolved "https://registry.yarnpkg.com/electron-spellchecker/-/electron-spellchecker-1.2.0.tgz#f6306afd4078244c1e6311370667d95b873fbcbb" 2036 resolved "https://registry.yarnpkg.com/electron-spellchecker/-/electron-spellchecker-1.1.2.tgz#5fbe1e65d246b77e6e7433ee2387d9d26010f7a8"
2019 dependencies: 2037 dependencies:
2020 "@paulcbetts/cld" "^2.4.6" 2038 "@paulcbetts/cld" "^2.4.6"
2021 "@paulcbetts/spellchecker" "^4.0.5" 2039 "@paulcbetts/spellchecker" "^4.0.6"
2022 bcp47 "^1.1.2" 2040 bcp47 "^1.1.2"
2023 debug "^2.6.3" 2041 debug "^2.6.3"
2024 electron-remote "^1.1.1" 2042 electron-remote "^1.1.1"
@@ -2724,6 +2742,16 @@ gaze@^1.0.0:
2724 dependencies: 2742 dependencies:
2725 globule "^1.0.0" 2743 globule "^1.0.0"
2726 2744
2745generate-function@^2.0.0:
2746 version "2.0.0"
2747 resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74"
2748
2749generate-object-property@^1.1.0:
2750 version "1.2.0"
2751 resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0"
2752 dependencies:
2753 is-property "^1.0.0"
2754
2727get-caller-file@^1.0.1: 2755get-caller-file@^1.0.1:
2728 version "1.0.2" 2756 version "1.0.2"
2729 resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" 2757 resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
@@ -3118,6 +3146,15 @@ har-schema@^1.0.5:
3118 version "1.0.5" 3146 version "1.0.5"
3119 resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" 3147 resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
3120 3148
3149har-validator@~2.0.6:
3150 version "2.0.6"
3151 resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d"
3152 dependencies:
3153 chalk "^1.1.1"
3154 commander "^2.9.0"
3155 is-my-json-valid "^2.12.4"
3156 pinkie-promise "^2.0.0"
3157
3121har-validator@~4.2.1: 3158har-validator@~4.2.1:
3122 version "4.2.1" 3159 version "4.2.1"
3123 resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" 3160 resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
@@ -3433,6 +3470,15 @@ is-glob@^3.1.0:
3433 dependencies: 3470 dependencies:
3434 is-extglob "^2.1.0" 3471 is-extglob "^2.1.0"
3435 3472
3473is-my-json-valid@^2.12.4:
3474 version "2.17.1"
3475 resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.1.tgz#3da98914a70a22f0a8563ef1511a246c6fc55471"
3476 dependencies:
3477 generate-function "^2.0.0"
3478 generate-object-property "^1.1.0"
3479 jsonpointer "^4.0.0"
3480 xtend "^4.0.0"
3481
3436is-npm@^1.0.0: 3482is-npm@^1.0.0:
3437 version "1.0.0" 3483 version "1.0.0"
3438 resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" 3484 resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
@@ -3491,6 +3537,10 @@ is-promise@^2.1.0:
3491 version "2.1.0" 3537 version "2.1.0"
3492 resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" 3538 resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
3493 3539
3540is-property@^1.0.0:
3541 version "1.0.2"
3542 resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
3543
3494is-redirect@^1.0.0: 3544is-redirect@^1.0.0:
3495 version "1.0.0" 3545 version "1.0.0"
3496 resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" 3546 resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
@@ -3686,6 +3736,10 @@ jsonify@~0.0.0:
3686 version "0.0.0" 3736 version "0.0.0"
3687 resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" 3737 resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
3688 3738
3739jsonpointer@^4.0.0:
3740 version "4.0.1"
3741 resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
3742
3689jsonwebtoken@^7.4.1: 3743jsonwebtoken@^7.4.1:
3690 version "7.4.3" 3744 version "7.4.3"
3691 resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz#77f5021de058b605a1783fa1283e99812e645638" 3745 resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz#77f5021de058b605a1783fa1283e99812e645638"
@@ -4227,9 +4281,9 @@ mksnapshot@^0.3.0:
4227 fs-extra "0.26.7" 4281 fs-extra "0.26.7"
4228 request "^2.79.0" 4282 request "^2.79.0"
4229 4283
4230mobx-react-form@1.24.0: 4284mobx-react-form@^1.32.2:
4231 version "1.24.0" 4285 version "1.32.2"
4232 resolved "https://registry.yarnpkg.com/mobx-react-form/-/mobx-react-form-1.24.0.tgz#bc9fbd652e65fb1f2b51917865d465fcaab7f0d9" 4286 resolved "https://registry.yarnpkg.com/mobx-react-form/-/mobx-react-form-1.32.2.tgz#5610dd0e4fab006acf2daf1becbedecad182a5a0"
4233 dependencies: 4287 dependencies:
4234 lodash "^4.16.2" 4288 lodash "^4.16.2"
4235 4289
@@ -4354,7 +4408,7 @@ node-pre-gyp@^0.6.36:
4354 tar "^2.2.1" 4408 tar "^2.2.1"
4355 tar-pack "^3.4.0" 4409 tar-pack "^3.4.0"
4356 4410
4357node-sass@^4.2.0, node-sass@^4.5.3: 4411node-sass@^4.2.0:
4358 version "4.5.3" 4412 version "4.5.3"
4359 resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.3.tgz#d09c9d1179641239d1b97ffc6231fdcec53e1568" 4413 resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.3.tgz#d09c9d1179641239d1b97ffc6231fdcec53e1568"
4360 dependencies: 4414 dependencies:
@@ -4377,6 +4431,30 @@ node-sass@^4.2.0, node-sass@^4.5.3:
4377 sass-graph "^2.1.1" 4431 sass-graph "^2.1.1"
4378 stdout-stream "^1.4.0" 4432 stdout-stream "^1.4.0"
4379 4433
4434node-sass@^4.7.2:
4435 version "4.7.2"
4436 resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.7.2.tgz#9366778ba1469eb01438a9e8592f4262bcb6794e"
4437 dependencies:
4438 async-foreach "^0.1.3"
4439 chalk "^1.1.1"
4440 cross-spawn "^3.0.0"
4441 gaze "^1.0.0"
4442 get-stdin "^4.0.1"
4443 glob "^7.0.3"
4444 in-publish "^2.0.0"
4445 lodash.assign "^4.2.0"
4446 lodash.clonedeep "^4.3.2"
4447 lodash.mergewith "^4.6.0"
4448 meow "^3.7.0"
4449 mkdirp "^0.5.1"
4450 nan "^2.3.2"
4451 node-gyp "^3.3.1"
4452 npmlog "^4.0.0"
4453 request "~2.79.0"
4454 sass-graph "^2.2.4"
4455 stdout-stream "^1.4.0"
4456 "true-case-path" "^1.0.2"
4457
4380node-watch@^0.3.4: 4458node-watch@^0.3.4:
4381 version "0.3.5" 4459 version "0.3.5"
4382 resolved "https://registry.yarnpkg.com/node-watch/-/node-watch-0.3.5.tgz#a07f253a4f538de9d4ca522dd7f1996eeec0d97e" 4460 resolved "https://registry.yarnpkg.com/node-watch/-/node-watch-0.3.5.tgz#a07f253a4f538de9d4ca522dd7f1996eeec0d97e"
@@ -4831,6 +4909,10 @@ pretty-bytes@^1.0.2, pretty-bytes@^1.0.4:
4831 get-stdin "^4.0.1" 4909 get-stdin "^4.0.1"
4832 meow "^3.1.0" 4910 meow "^3.1.0"
4833 4911
4912pretty-bytes@^4.0.2:
4913 version "4.0.2"
4914 resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
4915
4834pretty-hrtime@^1.0.0: 4916pretty-hrtime@^1.0.0:
4835 version "1.0.3" 4917 version "1.0.3"
4836 resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" 4918 resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@@ -4907,6 +4989,10 @@ q@^1.1.2:
4907 version "1.5.0" 4989 version "1.5.0"
4908 resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" 4990 resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
4909 4991
4992qs@~6.3.0:
4993 version "6.3.2"
4994 resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c"
4995
4910qs@~6.4.0: 4996qs@~6.4.0:
4911 version "6.4.0" 4997 version "6.4.0"
4912 resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" 4998 resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
@@ -4963,6 +5049,13 @@ react-dom@^15.4.1:
4963 object-assign "^4.1.0" 5049 object-assign "^4.1.0"
4964 prop-types "^15.5.10" 5050 prop-types "^15.5.10"
4965 5051
5052react-dropzone@^4.2.1:
5053 version "4.2.1"
5054 resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.2.1.tgz#695e80bd0b065f1181e69f2d0f6d1d5cc72664c9"
5055 dependencies:
5056 attr-accept "^1.0.3"
5057 prop-types "^15.5.7"
5058
4966react-electron-web-view@^2.0.1: 5059react-electron-web-view@^2.0.1:
4967 version "2.0.1" 5060 version "2.0.1"
4968 resolved "https://registry.yarnpkg.com/react-electron-web-view/-/react-electron-web-view-2.0.1.tgz#984b7bbbeb77e35bcca921dc50120fc8f2b0f27d" 5061 resolved "https://registry.yarnpkg.com/react-electron-web-view/-/react-electron-web-view-2.0.1.tgz#984b7bbbeb77e35bcca921dc50120fc8f2b0f27d"
@@ -5251,6 +5344,31 @@ request@2, request@^2.45.0, request@^2.54.0, request@^2.79.0, request@^2.81.0:
5251 tunnel-agent "^0.6.0" 5344 tunnel-agent "^0.6.0"
5252 uuid "^3.0.0" 5345 uuid "^3.0.0"
5253 5346
5347request@~2.79.0:
5348 version "2.79.0"
5349 resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de"
5350 dependencies:
5351 aws-sign2 "~0.6.0"
5352 aws4 "^1.2.1"
5353 caseless "~0.11.0"
5354 combined-stream "~1.0.5"
5355 extend "~3.0.0"
5356 forever-agent "~0.6.1"
5357 form-data "~2.1.1"
5358 har-validator "~2.0.6"
5359 hawk "~3.1.3"
5360 http-signature "~1.1.0"
5361 is-typedarray "~1.0.0"
5362 isstream "~0.1.2"
5363 json-stringify-safe "~5.0.1"
5364 mime-types "~2.1.7"
5365 oauth-sign "~0.8.1"
5366 qs "~6.3.0"
5367 stringstream "~0.0.4"
5368 tough-cookie "~2.3.0"
5369 tunnel-agent "~0.4.1"
5370 uuid "^3.0.0"
5371
5254require-directory@^2.1.1: 5372require-directory@^2.1.1:
5255 version "2.1.1" 5373 version "2.1.1"
5256 resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 5374 resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@@ -5354,7 +5472,7 @@ sanitize-filename@^1.6.0, sanitize-filename@^1.6.1:
5354 dependencies: 5472 dependencies:
5355 truncate-utf8-bytes "^1.0.0" 5473 truncate-utf8-bytes "^1.0.0"
5356 5474
5357sass-graph@^2.1.1: 5475sass-graph@^2.1.1, sass-graph@^2.2.4:
5358 version "2.2.4" 5476 version "2.2.4"
5359 resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" 5477 resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
5360 dependencies: 5478 dependencies:
@@ -5924,6 +6042,12 @@ trim-right@^1.0.1:
5924 version "1.0.1" 6042 version "1.0.1"
5925 resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" 6043 resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
5926 6044
6045"true-case-path@^1.0.2":
6046 version "1.0.2"
6047 resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.2.tgz#7ec91130924766c7f573be3020c34f8fdfd00d62"
6048 dependencies:
6049 glob "^6.0.4"
6050
5927truncate-utf8-bytes@^1.0.0: 6051truncate-utf8-bytes@^1.0.0:
5928 version "1.0.2" 6052 version "1.0.2"
5929 resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b" 6053 resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b"
@@ -5940,6 +6064,10 @@ tunnel-agent@^0.6.0:
5940 dependencies: 6064 dependencies:
5941 safe-buffer "^5.0.1" 6065 safe-buffer "^5.0.1"
5942 6066
6067tunnel-agent@~0.4.1:
6068 version "0.4.3"
6069 resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb"
6070
5943tweetnacl@^0.14.3, tweetnacl@~0.14.0: 6071tweetnacl@^0.14.3, tweetnacl@~0.14.0:
5944 version "0.14.5" 6072 version "0.14.5"
5945 resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" 6073 resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@@ -6299,7 +6427,7 @@ xmlhttprequest@^1.8.0:
6299 version "1.8.0" 6427 version "1.8.0"
6300 resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc" 6428 resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc"
6301 6429
6302"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: 6430"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1:
6303 version "4.0.1" 6431 version "4.0.1"
6304 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" 6432 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
6305 6433