aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Stefan Malzner <stefan@adlk.io>2018-01-04 10:18:16 +0100
committerLibravatar GitHub <noreply@github.com>2018-01-04 10:18:16 +0100
commitfc8c5ff94a1f6525afb9f103666113ad80eb3a43 (patch)
tree494b19ddbcde51c44e5de6a468fb89cdeba5835f
parentFix typo with badges (diff)
parentMerge pull request #517 from dannyqiu/service-cache-cleanup (diff)
downloadferdium-app-fc8c5ff94a1f6525afb9f103666113ad80eb3a43.tar.gz
ferdium-app-fc8c5ff94a1f6525afb9f103666113ad80eb3a43.tar.zst
ferdium-app-fc8c5ff94a1f6525afb9f103666113ad80eb3a43.zip
Merge branch 'develop' into bug-fixes
-rw-r--r--package.json2
-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.js34
-rw-r--r--src/api/server/ServerApi.js6
-rw-r--r--src/app.js2
-rw-r--r--src/components/settings/recipes/RecipesDashboard.js20
-rw-r--r--src/components/settings/services/ServicesDashboard.js42
-rw-r--r--src/components/settings/settings/EditSettingsForm.js38
-rw-r--r--src/components/ui/SearchInput.js48
-rw-r--r--src/containers/settings/EditSettingsScreen.js17
-rw-r--r--src/containers/settings/ServicesScreen.js1
-rw-r--r--src/helpers/service-helpers.js20
-rw-r--r--src/i18n/locales/en-US.json4
-rw-r--r--src/stores/AppStore.js60
-rw-r--r--src/stores/ServicesStore.js14
-rw-r--r--src/styles/button.scss12
-rw-r--r--src/styles/searchInput.scss16
-rw-r--r--src/styles/settings.scss22
-rw-r--r--yarn.lock14
22 files changed, 315 insertions, 76 deletions
diff --git a/package.json b/package.json
index 301b88b81..0a2bd8991 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
34 "babel-polyfill": "^6.23.0", 34 "babel-polyfill": "^6.23.0",
35 "babel-runtime": "^6.23.0", 35 "babel-runtime": "^6.23.0",
36 "classnames": "^2.2.5", 36 "classnames": "^2.2.5",
37 "du": "^0.1.0",
37 "electron-fetch": "^1.1.0", 38 "electron-fetch": "^1.1.0",
38 "electron-spellchecker": "^1.1.2", 39 "electron-spellchecker": "^1.1.2",
39 "electron-updater": "^2.4.3", 40 "electron-updater": "^2.4.3",
@@ -54,6 +55,7 @@
54 "mobx-react-router": "^3.1.2", 55 "mobx-react-router": "^3.1.2",
55 "moment": "^2.17.1", 56 "moment": "^2.17.1",
56 "normalize-url": "^1.9.1", 57 "normalize-url": "^1.9.1",
58 "pretty-bytes": "^4.0.2",
57 "prop-types": "^15.5.10", 59 "prop-types": "^15.5.10",
58 "prop-types-extended": "^0.2.1", 60 "prop-types-extended": "^0.2.1",
59 "react": "^15.4.1", 61 "react": "^15.4.1",
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 79ac6e12f..e95d750ac 100644
--- a/src/api/server/LocalApi.js
+++ b/src/api/server/LocalApi.js
@@ -1,3 +1,10 @@
1import { remote } from 'electron';
2import du from 'du';
3
4import { getServicePartitionsDirectory } from '../../helpers/service-helpers.js';
5
6const { session } = remote;
7
1export default class LocalApi { 8export default class LocalApi {
2 // App 9 // App
3 async updateAppSettings(data) { 10 async updateAppSettings(data) {
@@ -30,4 +37,31 @@ export default class LocalApi {
30 localStorage.setItem('app', JSON.stringify(settings)); 37 localStorage.setItem('app', JSON.stringify(settings));
31 } 38 }
32 } 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 }
33} 67}
diff --git a/src/api/server/ServerApi.js b/src/api/server/ServerApi.js
index 8b3136d27..d75d2e559 100644
--- a/src/api/server/ServerApi.js
+++ b/src/api/server/ServerApi.js
@@ -22,6 +22,10 @@ import {
22 loadRecipeConfig, 22 loadRecipeConfig,
23} from '../../helpers/recipe-helpers'; 23} from '../../helpers/recipe-helpers';
24 24
25import {
26 removeServicePartitionDirectory,
27} from '../../helpers/service-helpers.js';
28
25module.paths.unshift( 29module.paths.unshift(
26 getDevRecipeDirectory(), 30 getDevRecipeDirectory(),
27 getRecipeDirectory(), 31 getRecipeDirectory(),
@@ -210,6 +214,8 @@ export default class ServerApi {
210 } 214 }
211 const data = await request.json(); 215 const data = await request.json();
212 216
217 removeServicePartitionDirectory(id, true);
218
213 console.debug('ServerApi::deleteService resolves', data); 219 console.debug('ServerApi::deleteService resolves', data);
214 return data; 220 return data;
215 } 221 }
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/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/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 ff398aa33..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',
@@ -77,6 +89,9 @@ export default class EditSettingsForm extends Component {
77 isUpdateAvailable: PropTypes.bool.isRequired, 89 isUpdateAvailable: PropTypes.bool.isRequired,
78 noUpdateAvailable: PropTypes.bool.isRequired, 90 noUpdateAvailable: PropTypes.bool.isRequired,
79 updateIsReadyToInstall: PropTypes.bool.isRequired, 91 updateIsReadyToInstall: PropTypes.bool.isRequired,
92 isClearingAllCache: PropTypes.bool.isRequired,
93 onClearAllCache: PropTypes.func.isRequired,
94 cacheSize: PropTypes.string.isRequired,
80 }; 95 };
81 96
82 static contextTypes = { 97 static contextTypes = {
@@ -103,6 +118,9 @@ export default class EditSettingsForm extends Component {
103 isUpdateAvailable, 118 isUpdateAvailable,
104 noUpdateAvailable, 119 noUpdateAvailable,
105 updateIsReadyToInstall, 120 updateIsReadyToInstall,
121 isClearingAllCache,
122 onClearAllCache,
123 cacheSize,
106 } = this.props; 124 } = this.props;
107 const { intl } = this.context; 125 const { intl } = this.context;
108 126
@@ -155,6 +173,25 @@ export default class EditSettingsForm extends Component {
155 <h2 id="advanced">{intl.formatMessage(messages.headlineAdvanced)}</h2> 173 <h2 id="advanced">{intl.formatMessage(messages.headlineAdvanced)}</h2>
156 <Toggle field={form.$('enableSpellchecking')} /> 174 <Toggle field={form.$('enableSpellchecking')} />
157 {/* <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>
158 195
159 {/* Updates */} 196 {/* Updates */}
160 <h2 id="updates">{intl.formatMessage(messages.headlineUpdates)}</h2> 197 <h2 id="updates">{intl.formatMessage(messages.headlineUpdates)}</h2>
@@ -165,6 +202,7 @@ export default class EditSettingsForm extends Component {
165 /> 202 />
166 ) : ( 203 ) : (
167 <Button 204 <Button
205 buttonType="secondary"
168 label={intl.formatMessage(updateButtonLabelMessage)} 206 label={intl.formatMessage(updateButtonLabelMessage)}
169 onClick={checkForUpdates} 207 onClick={checkForUpdates}
170 disabled={isCheckingForUpdates || isUpdateAvailable} 208 disabled={isCheckingForUpdates || isUpdateAvailable}
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/containers/settings/EditSettingsScreen.js b/src/containers/settings/EditSettingsScreen.js
index 45ded9e5c..9fa815a0a 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/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/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 567537d75..2bfbf2455 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",
@@ -149,6 +150,9 @@
149 "settings.app.updateStatusSearching": "Is searching for update", 150 "settings.app.updateStatusSearching": "Is searching for update",
150 "settings.app.updateStatusAvailable": "Update available, downloading...", 151 "settings.app.updateStatusAvailable": "Update available, downloading...",
151 "settings.app.updateStatusUpToDate": "You are using the latest version of Franz", 152 "settings.app.updateStatusUpToDate": "You are using the latest version of Franz",
153 "settings.app.subheadlineCache": "Cache",
154 "settings.app.cacheInfo": "Franz cache is currently using {size} of disk space.",
155 "settings.app.buttonClearAllCache": "Clear cache",
152 "settings.app.form.autoLaunchOnStart": "Launch Franz on start", 156 "settings.app.form.autoLaunchOnStart": "Launch Franz on start",
153 "settings.app.form.autoLaunchInBackground": "Open in background", 157 "settings.app.form.autoLaunchInBackground": "Open in background",
154 "settings.app.form.enableSystemTray": "Show Franz in system tray", 158 "settings.app.form.enableSystemTray": "Show Franz in system tray",
diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js
index 5a6c12ee1..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),
@@ -124,15 +133,23 @@ export default class AppStore extends Store {
124 this.stores.router.push(data.url); 133 this.stores.router.push(data.url);
125 }); 134 });
126 135
136 const TIMEOUT = 5000;
127 // Check system idle time every minute 137 // Check system idle time every minute
128 setInterval(() => { 138 setInterval(() => {
129 this.idleTime = idleTimer.getIdleTime(); 139 this.idleTime = idleTimer.getIdleTime();
130 }, 60000); 140 }, TIMEOUT);
131 141
132 // Reload all services after a healthy nap 142 // Reload all services after a healthy nap
133 powerMonitor.on('resume', () => { 143 // Alternative solution for powerMonitor as the resume event is not fired
134 setTimeout(window.location.reload, 5000); 144 // More information: https://github.com/electron/electron/issues/1615
135 }); 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);
136 153
137 // Set active the next service 154 // Set active the next service
138 key( 155 key(
@@ -157,6 +174,10 @@ export default class AppStore extends Store {
157 this._healthCheck(); 174 this._healthCheck();
158 } 175 }
159 176
177 @computed get cacheSize() {
178 return prettyBytes(this.getAppCacheSizeRequest.execute().result || 0);
179 }
180
160 // Actions 181 // Actions
161 @action _notify({ title, options, notificationId, serviceId = null }) { 182 @action _notify({ title, options, notificationId, serviceId = null }) {
162 if (this.stores.settings.all.isAppMuted) return; 183 if (this.stores.settings.all.isAppMuted) return;
@@ -247,6 +268,23 @@ export default class AppStore extends Store {
247 this._muteApp({ isMuted: !this.stores.settings.all.isAppMuted }); 268 this._muteApp({ isMuted: !this.stores.settings.all.isAppMuted });
248 } 269 }
249 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
250 // Reactions 288 // Reactions
251 _offlineCheck() { 289 _offlineCheck() {
252 if (!this.isOnline) { 290 if (!this.isOnline) {
@@ -357,6 +395,16 @@ export default class AppStore extends Store {
357 return autoLauncher.isEnabled() || false; 395 return autoLauncher.isEnabled() || false;
358 } 396 }
359 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
360 _systemDND() { 408 _systemDND() {
361 const dnd = getDoNotDisturb(); 409 const dnd = getDoNotDisturb();
362 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 66f37af26..87ee57a0d 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));
@@ -205,6 +207,13 @@ export default class ServicesStore extends Store {
205 gaEvent('Service', 'delete', service.recipe.id); 207 gaEvent('Service', 'delete', service.recipe.id);
206 } 208 }
207 209
210 @action async _clearCache({ serviceId }) {
211 this.clearCacheRequest.reset();
212 const request = this.clearCacheRequest.execute(serviceId);
213 await request._promise;
214 gaEvent('Service', 'clear cache');
215 }
216
208 @action _setActive({ serviceId }) { 217 @action _setActive({ serviceId }) {
209 const service = this.one(serviceId); 218 const service = this.one(serviceId);
210 219
@@ -368,7 +377,7 @@ export default class ServicesStore extends Store {
368 const service = this.one(serviceId); 377 const service = this.one(serviceId);
369 service.resetMessageCount(); 378 service.resetMessageCount();
370 379
371 service.webview.reload(); 380 service.webview.loadURL(service.url);
372 } 381 }
373 382
374 @action _reloadActive() { 383 @action _reloadActive() {
@@ -497,12 +506,13 @@ export default class ServicesStore extends Store {
497 .reduce((a, b) => a + b, 0); 506 .reduce((a, b) => a + b, 0);
498 507
499 const unreadIndirectMessageCount = this.allDisplayed 508 const unreadIndirectMessageCount = this.allDisplayed
500 .filter(s => (showMessageBadgeWhenMuted || s.isIndirectMessageBadgeEnabled) && showMessageBadgesEvenWhenMuted && s.isBadgeEnabled) 509 .filter(s => (showMessageBadgeWhenMuted && showMessageBadgesEvenWhenMuted) && (s.isBadgeEnabled && s.isIndirectMessageBadgeEnabled))
501 .map(s => s.unreadIndirectMessageCount) 510 .map(s => s.unreadIndirectMessageCount)
502 .reduce((a, b) => a + b, 0); 511 .reduce((a, b) => a + b, 0);
503 512
504 // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases 513 // 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) { 514 if (showMessageBadgesEvenWhenMuted) {
515 console.log('set badge', unreadDirectMessageCount, unreadIndirectMessageCount);
506 this.actions.app.setBadge({ 516 this.actions.app.setBadge({
507 unreadDirectMessageCount, 517 unreadDirectMessageCount,
508 unreadIndirectMessageCount, 518 unreadIndirectMessageCount,
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/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/settings.scss b/src/styles/settings.scss
index b29ed5468..2da56c930 100644
--- a/src/styles/settings.scss
+++ b/src/styles/settings.scss
@@ -129,26 +129,8 @@
129 } 129 }
130 } 130 }
131 131
132 .settings__search-header { 132 .search-input {
133 display: flex; 133 margin-bottom: 30px;
134 align-items: center;
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
145 input {
146 padding-left: 10px;
147 background: none;
148 border: 0;
149 flex: 1;
150 @extend %headline;
151 }
152 } 134 }
153 135
154 &__options { 136 &__options {
diff --git a/yarn.lock b/yarn.lock
index 3dead4308..dbcb33647 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -333,6 +333,10 @@ 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"
@@ -1783,6 +1787,12 @@ dotenv@^4.0.0:
1783 version "4.0.0" 1787 version "4.0.0"
1784 resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" 1788 resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
1785 1789
1790du@^0.1.0:
1791 version "0.1.0"
1792 resolved "https://registry.yarnpkg.com/du/-/du-0.1.0.tgz#f26e340a09c7bc5b6fd69af6dbadea60fa8c6f4d"
1793 dependencies:
1794 async "~0.1.22"
1795
1786duplexer2@0.0.2, duplexer2@~0.0.2: 1796duplexer2@0.0.2, duplexer2@~0.0.2:
1787 version "0.0.2" 1797 version "0.0.2"
1788 resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db" 1798 resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db"
@@ -4895,6 +4905,10 @@ pretty-bytes@^1.0.2, pretty-bytes@^1.0.4:
4895 get-stdin "^4.0.1" 4905 get-stdin "^4.0.1"
4896 meow "^3.1.0" 4906 meow "^3.1.0"
4897 4907
4908pretty-bytes@^4.0.2:
4909 version "4.0.2"
4910 resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
4911
4898pretty-hrtime@^1.0.0: 4912pretty-hrtime@^1.0.0:
4899 version "1.0.3" 4913 version "1.0.3"
4900 resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" 4914 resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"