aboutsummaryrefslogtreecommitdiffstats
path: root/src/stores/ServicesStore.js
diff options
context:
space:
mode:
authorLibravatar Ricardo Cino <ricardo@cino.io>2022-06-23 18:10:39 +0200
committerLibravatar GitHub <noreply@github.com>2022-06-23 16:10:39 +0000
commit6b2c2b8dfb86245a1747bf7977159f5129461863 (patch)
tree28944f62a962d8a658262ea902f8554d4419fa9e /src/stores/ServicesStore.js
parentchore: featureStore and GlobalErrorStore JS => TS (diff)
downloadferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.tar.gz
ferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.tar.zst
ferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.zip
chore: servicesStore + models into typescript (#344)
Diffstat (limited to 'src/stores/ServicesStore.js')
-rw-r--r--src/stores/ServicesStore.js1319
1 files changed, 0 insertions, 1319 deletions
diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js
deleted file mode 100644
index 999b48d92..000000000
--- a/src/stores/ServicesStore.js
+++ /dev/null
@@ -1,1319 +0,0 @@
1import { shell } from 'electron';
2import { action, reaction, computed, observable } from 'mobx';
3import { debounce, remove } from 'lodash';
4import ms from 'ms';
5import { ensureFileSync, pathExistsSync, writeFileSync } from 'fs-extra';
6import { join } from 'path';
7
8import Store from './lib/Store';
9import Request from './lib/Request';
10import CachedRequest from './lib/CachedRequest';
11import { matchRoute } from '../helpers/routing-helpers';
12import { isInTimeframe } from '../helpers/schedule-helpers';
13import {
14 getRecipeDirectory,
15 getDevRecipeDirectory,
16} from '../helpers/recipe-helpers';
17import { workspaceStore } from '../features/workspaces';
18import { DEFAULT_SERVICE_SETTINGS, KEEP_WS_LOADED_USID } from '../config';
19import { cleanseJSObject } from '../jsUtils';
20import { SPELLCHECKER_LOCALES } from '../i18n/languages';
21import { ferdiumVersion } from '../environment-remote';
22
23const debug = require('../preload-safe-debug')('Ferdium:ServiceStore');
24
25export default class ServicesStore extends Store {
26 @observable allServicesRequest = new CachedRequest(this.api.services, 'all');
27
28 @observable createServiceRequest = new Request(this.api.services, 'create');
29
30 @observable updateServiceRequest = new Request(this.api.services, 'update');
31
32 @observable reorderServicesRequest = new Request(
33 this.api.services,
34 'reorder',
35 );
36
37 @observable deleteServiceRequest = new Request(this.api.services, 'delete');
38
39 @observable clearCacheRequest = new Request(this.api.services, 'clearCache');
40
41 @observable filterNeedle = null;
42
43 // Array of service IDs that have recently been used
44 // [0] => Most recent, [n] => Least recent
45 // No service ID should be in the list multiple times, not all service IDs have to be in the list
46 @observable lastUsedServices = [];
47
48 constructor(...args) {
49 super(...args);
50
51 // Register action handlers
52 this.actions.service.setActive.listen(this._setActive.bind(this));
53 this.actions.service.blurActive.listen(this._blurActive.bind(this));
54 this.actions.service.setActiveNext.listen(this._setActiveNext.bind(this));
55 this.actions.service.setActivePrev.listen(this._setActivePrev.bind(this));
56 this.actions.service.showAddServiceInterface.listen(
57 this._showAddServiceInterface.bind(this),
58 );
59 this.actions.service.createService.listen(this._createService.bind(this));
60 this.actions.service.createFromLegacyService.listen(
61 this._createFromLegacyService.bind(this),
62 );
63 this.actions.service.updateService.listen(this._updateService.bind(this));
64 this.actions.service.deleteService.listen(this._deleteService.bind(this));
65 this.actions.service.openRecipeFile.listen(this._openRecipeFile.bind(this));
66 this.actions.service.clearCache.listen(this._clearCache.bind(this));
67 this.actions.service.setWebviewReference.listen(
68 this._setWebviewReference.bind(this),
69 );
70 this.actions.service.detachService.listen(this._detachService.bind(this));
71 this.actions.service.focusService.listen(this._focusService.bind(this));
72 this.actions.service.focusActiveService.listen(
73 this._focusActiveService.bind(this),
74 );
75 this.actions.service.toggleService.listen(this._toggleService.bind(this));
76 this.actions.service.handleIPCMessage.listen(
77 this._handleIPCMessage.bind(this),
78 );
79 this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this));
80 this.actions.service.sendIPCMessageToAllServices.listen(
81 this._sendIPCMessageToAllServices.bind(this),
82 );
83 this.actions.service.setUnreadMessageCount.listen(
84 this._setUnreadMessageCount.bind(this),
85 );
86 this.actions.service.setDialogTitle.listen(this._setDialogTitle.bind(this));
87 this.actions.service.openWindow.listen(this._openWindow.bind(this));
88 this.actions.service.filter.listen(this._filter.bind(this));
89 this.actions.service.resetFilter.listen(this._resetFilter.bind(this));
90 this.actions.service.resetStatus.listen(this._resetStatus.bind(this));
91 this.actions.service.reload.listen(this._reload.bind(this));
92 this.actions.service.reloadActive.listen(this._reloadActive.bind(this));
93 this.actions.service.reloadAll.listen(this._reloadAll.bind(this));
94 this.actions.service.reloadUpdatedServices.listen(
95 this._reloadUpdatedServices.bind(this),
96 );
97 this.actions.service.reorder.listen(this._reorder.bind(this));
98 this.actions.service.toggleNotifications.listen(
99 this._toggleNotifications.bind(this),
100 );
101 this.actions.service.toggleAudio.listen(this._toggleAudio.bind(this));
102 this.actions.service.toggleDarkMode.listen(this._toggleDarkMode.bind(this));
103 this.actions.service.openDevTools.listen(this._openDevTools.bind(this));
104 this.actions.service.openDevToolsForActiveService.listen(
105 this._openDevToolsForActiveService.bind(this),
106 );
107 this.actions.service.hibernate.listen(this._hibernate.bind(this));
108 this.actions.service.awake.listen(this._awake.bind(this));
109 this.actions.service.resetLastPollTimer.listen(
110 this._resetLastPollTimer.bind(this),
111 );
112 this.actions.service.shareSettingsWithServiceProcess.listen(
113 this._shareSettingsWithServiceProcess.bind(this),
114 );
115
116 this.registerReactions([
117 this._focusServiceReaction.bind(this),
118 this._getUnreadMessageCountReaction.bind(this),
119 this._mapActiveServiceToServiceModelReaction.bind(this),
120 this._saveActiveService.bind(this),
121 this._logoutReaction.bind(this),
122 this._handleMuteSettings.bind(this),
123 this._checkForActiveService.bind(this),
124 ]);
125
126 // Just bind this
127 this._initializeServiceRecipeInWebview.bind(this);
128 }
129
130 setup() {
131 // Single key reactions for the sake of your CPU
132 reaction(
133 () => this.stores.settings.app.enableSpellchecking,
134 () => {
135 this._shareSettingsWithServiceProcess();
136 },
137 );
138
139 reaction(
140 () => this.stores.settings.app.spellcheckerLanguage,
141 () => {
142 this._shareSettingsWithServiceProcess();
143 },
144 );
145
146 reaction(
147 () => this.stores.settings.app.darkMode,
148 () => {
149 this._shareSettingsWithServiceProcess();
150 },
151 );
152
153 reaction(
154 () => this.stores.settings.app.adaptableDarkMode,
155 () => {
156 this._shareSettingsWithServiceProcess();
157 },
158 );
159
160 reaction(
161 () => this.stores.settings.app.universalDarkMode,
162 () => {
163 this._shareSettingsWithServiceProcess();
164 },
165 );
166
167 reaction(
168 () => this.stores.settings.app.splitMode,
169 () => {
170 this._shareSettingsWithServiceProcess();
171 },
172 );
173
174 reaction(
175 () => this.stores.settings.app.splitColumns,
176 () => {
177 this._shareSettingsWithServiceProcess();
178 },
179 );
180
181 reaction(
182 () => this.stores.settings.app.searchEngine,
183 () => {
184 this._shareSettingsWithServiceProcess();
185 },
186 );
187
188 reaction(
189 () => this.stores.settings.app.clipboardNotifications,
190 () => {
191 this._shareSettingsWithServiceProcess();
192 },
193 );
194 }
195
196 initialize() {
197 super.initialize();
198
199 // Check services to become hibernated
200 this.serviceMaintenanceTick();
201 }
202
203 teardown() {
204 super.teardown();
205
206 // Stop checking services for hibernation
207 this.serviceMaintenanceTick.cancel();
208 }
209
210 /**
211 * Сheck for services to become hibernated.
212 */
213 serviceMaintenanceTick = debounce(() => {
214 this._serviceMaintenance();
215 this.serviceMaintenanceTick();
216 debug('Service maintenance tick');
217 }, ms('10s'));
218
219 /**
220 * Run various maintenance tasks on services
221 */
222 _serviceMaintenance() {
223 for (const service of this.enabled) {
224 // Defines which services should be hibernated or woken up
225 if (!service.isActive) {
226 if (
227 !service.lastHibernated &&
228 Date.now() - service.lastUsed >
229 ms(`${this.stores.settings.all.app.hibernationStrategy}s`)
230 ) {
231 // If service is stale, hibernate it.
232 this._hibernate({ serviceId: service.id });
233 }
234
235 if (
236 service.isWakeUpEnabled &&
237 service.lastHibernated &&
238 Number(this.stores.settings.all.app.wakeUpStrategy) > 0 &&
239 Date.now() - service.lastHibernated >
240 ms(`${this.stores.settings.all.app.wakeUpStrategy}s`)
241 ) {
242 // If service is in hibernation and the wakeup time has elapsed, wake it.
243 this._awake({ serviceId: service.id, automatic: true });
244 }
245 }
246
247 if (
248 service.lastPoll &&
249 service.lastPoll - service.lastPollAnswer > ms('1m')
250 ) {
251 // If service did not reply for more than 1m try to reload.
252 if (!service.isActive) {
253 if (this.stores.app.isOnline && service.lostRecipeReloadAttempt < 3) {
254 debug(
255 `Reloading service: ${service.name} (${service.id}). Attempt: ${service.lostRecipeReloadAttempt}`,
256 );
257 // service.webview.reload();
258 service.lostRecipeReloadAttempt += 1;
259
260 service.lostRecipeConnection = false;
261 }
262 } else {
263 debug(`Service lost connection: ${service.name} (${service.id}).`);
264 service.lostRecipeConnection = true;
265 }
266 } else {
267 service.lostRecipeConnection = false;
268 service.lostRecipeReloadAttempt = 0;
269 }
270 }
271 }
272
273 // Computed props
274 @computed get all() {
275 if (this.stores.user.isLoggedIn) {
276 const services = this.allServicesRequest.execute().result;
277 if (services) {
278 return observable(
279 [...services]
280 .slice()
281 .sort((a, b) => a.order - b.order)
282 .map((s, index) => {
283 s.index = index;
284 return s;
285 }),
286 );
287 }
288 }
289 return [];
290 }
291
292 @computed get enabled() {
293 return this.all.filter(service => service.isEnabled);
294 }
295
296 @computed get allDisplayed() {
297 const services = this.stores.settings.all.app.showDisabledServices
298 ? this.all
299 : this.enabled;
300 return workspaceStore.filterServicesByActiveWorkspace(services);
301 }
302
303 // This is just used to avoid unnecessary rerendering of resource-heavy webviews
304 @computed get allDisplayedUnordered() {
305 const { showDisabledServices } = this.stores.settings.all.app;
306 const { keepAllWorkspacesLoaded } = this.stores.workspaces.settings;
307 const services = this.allServicesRequest.execute().result || [];
308 const filteredServices = showDisabledServices
309 ? services
310 : services.filter(service => service.isEnabled);
311
312 let displayedServices;
313 if (keepAllWorkspacesLoaded) {
314 // Keep all enabled services loaded
315 displayedServices = filteredServices;
316 } else {
317 // Keep all services in current workspace loaded
318 displayedServices =
319 workspaceStore.filterServicesByActiveWorkspace(filteredServices);
320
321 // Keep all services active in workspaces that should be kept loaded
322 for (const workspace of this.stores.workspaces.workspaces) {
323 // Check if workspace needs to be kept loaded
324 if (workspace.services.includes(KEEP_WS_LOADED_USID)) {
325 // Get services for workspace
326 const serviceIDs = new Set(
327 workspace.services.filter(i => i !== KEEP_WS_LOADED_USID),
328 );
329 const wsServices = filteredServices.filter(service =>
330 serviceIDs.has(service.id),
331 );
332
333 displayedServices = [...displayedServices, ...wsServices];
334 }
335 }
336
337 // Make sure every service is in the list only once
338 displayedServices = displayedServices.filter(
339 (v, i, a) => a.indexOf(v) === i,
340 );
341 }
342
343 return displayedServices;
344 }
345
346 @computed get filtered() {
347 return this.all.filter(service =>
348 service.name.toLowerCase().includes(this.filterNeedle.toLowerCase()),
349 );
350 }
351
352 @computed get active() {
353 return this.all.find(service => service.isActive);
354 }
355
356 @computed get activeSettings() {
357 const match = matchRoute(
358 '/settings/services/edit/:id',
359 this.stores.router.location.pathname,
360 );
361 if (match) {
362 const activeService = this.one(match.id);
363 if (activeService) {
364 return activeService;
365 }
366
367 debug('Service not available');
368 }
369
370 return null;
371 }
372
373 @computed get isTodosServiceAdded() {
374 return (
375 this.allDisplayed.find(
376 service => service.isTodosService && service.isEnabled,
377 ) || false
378 );
379 }
380
381 @computed get isTodosServiceActive() {
382 return this.active && this.active.isTodosService;
383 }
384
385 one(id) {
386 return this.all.find(service => service.id === id);
387 }
388
389 async _showAddServiceInterface({ recipeId }) {
390 this.stores.router.push(`/settings/services/add/${recipeId}`);
391 }
392
393 // Actions
394 async _createService({
395 recipeId,
396 serviceData,
397 redirect = true,
398 skipCleanup = false,
399 }) {
400 if (!this.stores.recipes.isInstalled(recipeId)) {
401 debug(`Recipe "${recipeId}" is not installed, installing recipe`);
402 await this.stores.recipes._install({ recipeId });
403 debug(`Recipe "${recipeId}" installed`);
404 }
405
406 // set default values for serviceData
407 serviceData = {
408 isEnabled: DEFAULT_SERVICE_SETTINGS.isEnabled,
409 isHibernationEnabled: DEFAULT_SERVICE_SETTINGS.isHibernationEnabled,
410 isWakeUpEnabled: DEFAULT_SERVICE_SETTINGS.isWakeUpEnabled,
411 isNotificationEnabled: DEFAULT_SERVICE_SETTINGS.isNotificationEnabled,
412 isBadgeEnabled: DEFAULT_SERVICE_SETTINGS.isBadgeEnabled,
413 trapLinkClicks: DEFAULT_SERVICE_SETTINGS.trapLinkClicks,
414 isMuted: DEFAULT_SERVICE_SETTINGS.isMuted,
415 customIcon: DEFAULT_SERVICE_SETTINGS.customIcon,
416 isDarkModeEnabled: DEFAULT_SERVICE_SETTINGS.isDarkModeEnabled,
417 isProgressbarEnabled: DEFAULT_SERVICE_SETTINGS.isProgressbarEnabled,
418 spellcheckerLanguage:
419 SPELLCHECKER_LOCALES[this.stores.settings.app.spellcheckerLanguage],
420 userAgentPref: '',
421 ...serviceData,
422 };
423
424 const data = skipCleanup
425 ? serviceData
426 : this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData);
427
428 const response = await this.createServiceRequest.execute(recipeId, data)
429 ._promise;
430
431 this.allServicesRequest.patch(result => {
432 if (!result) return;
433 result.push(response.data);
434 });
435
436 this.actions.settings.update({
437 type: 'proxy',
438 data: {
439 [`${response.data.id}`]: data.proxy,
440 },
441 });
442
443 this.actionStatus = response.status || [];
444
445 if (redirect) {
446 this.stores.router.push('/settings/recipes');
447 }
448 }
449
450 @action async _createFromLegacyService({ data }) {
451 const { id } = data.recipe;
452 const serviceData = {};
453
454 if (data.name) {
455 serviceData.name = data.name;
456 }
457
458 if (data.team) {
459 if (!data.customURL) {
460 serviceData.team = data.team;
461 } else {
462 // TODO: Is this correct?
463 serviceData.customUrl = data.team;
464 }
465 }
466
467 this.actions.service.createService({
468 recipeId: id,
469 serviceData,
470 redirect: false,
471 });
472 }
473
474 @action async _updateService({ serviceId, serviceData, redirect = true }) {
475 const service = this.one(serviceId);
476 const data = this._cleanUpTeamIdAndCustomUrl(
477 service.recipe.id,
478 serviceData,
479 );
480 const request = this.updateServiceRequest.execute(serviceId, data);
481
482 const newData = serviceData;
483 if (serviceData.iconFile) {
484 await request._promise;
485
486 newData.iconUrl = request.result.data.iconUrl;
487 newData.hasCustomUploadedIcon = true;
488 }
489
490 this.allServicesRequest.patch(result => {
491 if (!result) return;
492
493 // patch custom icon deletion
494 if (data.customIcon === 'delete') {
495 newData.iconUrl = '';
496 newData.hasCustomUploadedIcon = false;
497 }
498
499 // patch custom icon url
500 if (data.customIconUrl) {
501 newData.iconUrl = data.customIconUrl;
502 }
503
504 Object.assign(
505 result.find(c => c.id === serviceId),
506 newData,
507 );
508 });
509
510 await request._promise;
511 this.actionStatus = request.result.status;
512
513 if (service.isEnabled) {
514 this._sendIPCMessage({
515 serviceId,
516 channel: 'service-settings-update',
517 args: newData,
518 });
519 }
520
521 this.actions.settings.update({
522 type: 'proxy',
523 data: {
524 [`${serviceId}`]: data.proxy,
525 },
526 });
527
528 if (redirect) {
529 this.stores.router.push('/settings/services');
530 }
531 }
532
533 @action async _deleteService({ serviceId, redirect }) {
534 const request = this.deleteServiceRequest.execute(serviceId);
535
536 if (redirect) {
537 this.stores.router.push(redirect);
538 }
539
540 this.allServicesRequest.patch(result => {
541 remove(result, c => c.id === serviceId);
542 });
543
544 await request._promise;
545 this.actionStatus = request.result.status;
546 }
547
548 @action async _openRecipeFile({ recipe, file }) {
549 // Get directory for recipe
550 const normalDirectory = getRecipeDirectory(recipe);
551 const devDirectory = getDevRecipeDirectory(recipe);
552 let directory;
553
554 if (pathExistsSync(normalDirectory)) {
555 directory = normalDirectory;
556 } else if (pathExistsSync(devDirectory)) {
557 directory = devDirectory;
558 } else {
559 // Recipe cannot be found on drive
560 return;
561 }
562
563 // Create and open file
564 const filePath = join(directory, file);
565 if (file === 'user.js') {
566 if (!pathExistsSync(filePath)) {
567 writeFileSync(
568 filePath,
569 `module.exports = (config, Ferdium) => {
570 // Write your scripts here
571 console.log("Hello, World!", config);
572};
573`,
574 );
575 }
576 } else {
577 ensureFileSync(filePath);
578 }
579 shell.showItemInFolder(filePath);
580 }
581
582 @action async _clearCache({ serviceId }) {
583 this.clearCacheRequest.reset();
584 const request = this.clearCacheRequest.execute(serviceId);
585 await request._promise;
586 }
587
588 @action _setActive({ serviceId, keepActiveRoute = null }) {
589 if (!keepActiveRoute) this.stores.router.push('/');
590 const service = this.one(serviceId);
591
592 for (const s of this.all) {
593 if (s.isActive) {
594 s.lastUsed = Date.now();
595 s.isActive = false;
596 }
597 }
598 service.isActive = true;
599 this._awake({ serviceId: service.id });
600
601 if (
602 this.isTodosServiceActive &&
603 !this.stores.todos.settings.isFeatureEnabledByUser
604 ) {
605 this.actions.todos.toggleTodosFeatureVisibility();
606 }
607
608 // Update list of last used services
609 this.lastUsedServices = this.lastUsedServices.filter(
610 id => id !== serviceId,
611 );
612 this.lastUsedServices.unshift(serviceId);
613
614 this._focusActiveService();
615 }
616
617 @action _blurActive() {
618 const service = this.active;
619 if (service) {
620 service.isActive = false;
621 } else {
622 debug('No service is active');
623 }
624 }
625
626 @action _setActiveNext() {
627 const nextIndex = this._wrapIndex(
628 this.allDisplayed.findIndex(service => service.isActive),
629 1,
630 this.allDisplayed.length,
631 );
632
633 this._setActive({ serviceId: this.allDisplayed[nextIndex].id });
634 }
635
636 @action _setActivePrev() {
637 const prevIndex = this._wrapIndex(
638 this.allDisplayed.findIndex(service => service.isActive),
639 -1,
640 this.allDisplayed.length,
641 );
642
643 this._setActive({ serviceId: this.allDisplayed[prevIndex].id });
644 }
645
646 @action _setUnreadMessageCount({ serviceId, count }) {
647 const service = this.one(serviceId);
648
649 service.unreadDirectMessageCount = count.direct;
650 service.unreadIndirectMessageCount = count.indirect;
651 }
652
653 @action _setDialogTitle({ serviceId, dialogTitle }) {
654 const service = this.one(serviceId);
655
656 service.dialogTitle = dialogTitle;
657 }
658
659 @action _setWebviewReference({ serviceId, webview }) {
660 const service = this.one(serviceId);
661 if (service) {
662 service.webview = webview;
663
664 if (!service.isAttached) {
665 debug('Webview is not attached, initializing');
666 service.initializeWebViewEvents({
667 handleIPCMessage: this.actions.service.handleIPCMessage,
668 openWindow: this.actions.service.openWindow,
669 stores: this.stores,
670 });
671 service.initializeWebViewListener();
672 }
673 service.isAttached = true;
674 }
675 }
676
677 @action _detachService({ service }) {
678 service.webview = null;
679 service.isAttached = false;
680 }
681
682 @action _focusService({ serviceId }) {
683 const service = this.one(serviceId);
684
685 if (service.webview) {
686 service.webview.blur();
687 service.webview.focus();
688 }
689 }
690
691 @action _focusActiveService(focusEvent = null) {
692 if (this.stores.user.isLoggedIn) {
693 // TODO: add checks to not focus service when router path is /settings or /auth
694 const service = this.active;
695 if (service) {
696 if (service._webview) {
697 document.title = `Ferdium - ${service.name} ${
698 service.dialogTitle ? ` - ${service.dialogTitle}` : ''
699 } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`;
700 this._focusService({ serviceId: service.id });
701 if (this.stores.settings.app.splitMode && !focusEvent) {
702 setTimeout(() => {
703 document
704 .querySelector('.services__webview-wrapper.is-active')
705 .scrollIntoView({
706 behavior: 'smooth',
707 block: 'end',
708 inline: 'nearest',
709 });
710 }, 10);
711 }
712 }
713 } else {
714 debug('No service is active');
715 }
716 } else {
717 this.allServicesRequest.invalidate();
718 }
719 }
720
721 @action _toggleService({ serviceId }) {
722 const service = this.one(serviceId);
723
724 service.isEnabled = !service.isEnabled;
725 }
726
727 @action _handleIPCMessage({ serviceId, channel, args }) {
728 const service = this.one(serviceId);
729
730 // eslint-disable-next-line default-case
731 switch (channel) {
732 case 'hello': {
733 debug('Received hello event from', serviceId);
734
735 this._initRecipePolling(service.id);
736 this._initializeServiceRecipeInWebview(serviceId);
737 this._shareSettingsWithServiceProcess();
738
739 break;
740 }
741 case 'alive': {
742 service.lastPollAnswer = Date.now();
743
744 break;
745 }
746 case 'message-counts': {
747 debug(`Received unread message info from '${serviceId}'`, args[0]);
748
749 this.actions.service.setUnreadMessageCount({
750 serviceId,
751 count: {
752 direct: args[0].direct,
753 indirect: args[0].indirect,
754 },
755 });
756
757 break;
758 }
759 case 'active-dialog-title': {
760 debug(`Received active dialog title from '${serviceId}'`, args[0]);
761
762 this.actions.service.setDialogTitle({
763 serviceId,
764 dialogTitle: args[0],
765 });
766
767 break;
768 }
769 case 'notification': {
770 const { options } = args[0];
771
772 // Check if we are in scheduled Do-not-Disturb time
773 const { scheduledDNDEnabled, scheduledDNDStart, scheduledDNDEnd } =
774 this.stores.settings.all.app;
775
776 if (
777 scheduledDNDEnabled &&
778 isInTimeframe(scheduledDNDStart, scheduledDNDEnd)
779 ) {
780 return;
781 }
782
783 if (
784 service.recipe.hasNotificationSound ||
785 service.isMuted ||
786 this.stores.settings.all.app.isAppMuted
787 ) {
788 Object.assign(options, {
789 silent: true,
790 });
791 }
792
793 if (service.isNotificationEnabled) {
794 let title = `Notification from ${service.name}`;
795 if (!this.stores.settings.all.app.privateNotifications) {
796 options.body = typeof options.body === 'string' ? options.body : '';
797 title =
798 typeof args[0].title === 'string' ? args[0].title : service.name;
799 } else {
800 // Remove message data from notification in private mode
801 options.body = '';
802 options.icon = '/assets/img/notification-badge.gif';
803 }
804
805 this.actions.app.notify({
806 notificationId: args[0].notificationId,
807 title,
808 options,
809 serviceId,
810 });
811 }
812
813 break;
814 }
815 case 'avatar': {
816 const url = args[0];
817 if (service.iconUrl !== url && !service.hasCustomUploadedIcon) {
818 service.customIconUrl = url;
819
820 this.actions.service.updateService({
821 serviceId,
822 serviceData: {
823 customIconUrl: url,
824 },
825 redirect: false,
826 });
827 }
828
829 break;
830 }
831 case 'new-window': {
832 const url = args[0];
833
834 this.actions.app.openExternalUrl({ url });
835
836 break;
837 }
838 case 'set-service-spellchecker-language': {
839 if (!args) {
840 console.warn('Did not receive locale');
841 } else {
842 this.actions.service.updateService({
843 serviceId,
844 serviceData: {
845 spellcheckerLanguage: args[0] === 'reset' ? '' : args[0],
846 },
847 redirect: false,
848 });
849 }
850
851 break;
852 }
853 case 'feature:todos': {
854 Object.assign(args[0].data, { serviceId });
855 this.actions.todos.handleHostMessage(args[0]);
856
857 break;
858 }
859 // No default
860 }
861 }
862
863 @action _sendIPCMessage({ serviceId, channel, args }) {
864 const service = this.one(serviceId);
865
866 // Make sure the args are clean, otherwise ElectronJS can't transmit them
867 const cleanArgs = cleanseJSObject(args);
868
869 if (service.webview) {
870 service.webview.send(channel, cleanArgs);
871 }
872 }
873
874 @action _sendIPCMessageToAllServices({ channel, args }) {
875 for (const s of this.all) {
876 this.actions.service.sendIPCMessage({
877 serviceId: s.id,
878 channel,
879 args,
880 });
881 }
882 }
883
884 @action _openWindow({ event }) {
885 if (event.url !== 'about:blank') {
886 event.preventDefault();
887 this.actions.app.openExternalUrl({ url: event.url });
888 }
889 }
890
891 @action _filter({ needle }) {
892 this.filterNeedle = needle;
893 }
894
895 @action _resetFilter() {
896 this.filterNeedle = null;
897 }
898
899 @action _resetStatus() {
900 this.actionStatus = [];
901 }
902
903 @action _reload({ serviceId }) {
904 const service = this.one(serviceId);
905 if (!service.isEnabled) return;
906
907 service.resetMessageCount();
908 service.lostRecipeConnection = false;
909
910 if (service.isTodosService) {
911 return this.actions.todos.reload();
912 }
913
914 if (!service.webview) return;
915 return service.webview.loadURL(service.url);
916 }
917
918 @action _reloadActive() {
919 const service = this.active;
920 if (service) {
921 this._reload({
922 serviceId: service.id,
923 });
924 } else {
925 debug('No service is active');
926 }
927 }
928
929 @action _reloadAll() {
930 for (const s of this.enabled) {
931 this._reload({
932 serviceId: s.id,
933 });
934 }
935 }
936
937 @action _reloadUpdatedServices() {
938 this._reloadAll();
939 this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false });
940 }
941
942 @action _reorder(params) {
943 const { workspaces } = this.stores;
944 if (workspaces.isAnyWorkspaceActive) {
945 workspaces.reorderServicesOfActiveWorkspace(params);
946 } else {
947 this._reorderService(params);
948 }
949 }
950
951 @action _reorderService({ oldIndex, newIndex }) {
952 const { showDisabledServices } = this.stores.settings.all.app;
953 const oldEnabledSortIndex = showDisabledServices
954 ? oldIndex
955 : this.all.indexOf(this.enabled[oldIndex]);
956 const newEnabledSortIndex = showDisabledServices
957 ? newIndex
958 : this.all.indexOf(this.enabled[newIndex]);
959
960 this.all.splice(
961 newEnabledSortIndex,
962 0,
963 this.all.splice(oldEnabledSortIndex, 1)[0],
964 );
965
966 const services = {};
967 // TODO: simplify this
968 for (const [index] of this.all.entries()) {
969 services[this.all[index].id] = index;
970 }
971
972 this.reorderServicesRequest.execute(services);
973 this.allServicesRequest.patch(data => {
974 for (const s of data) {
975 const service = s;
976
977 service.order = services[s.id];
978 }
979 });
980 }
981
982 @action _toggleNotifications({ serviceId }) {
983 const service = this.one(serviceId);
984
985 this.actions.service.updateService({
986 serviceId,
987 serviceData: {
988 isNotificationEnabled: !service.isNotificationEnabled,
989 },
990 redirect: false,
991 });
992 }
993
994 @action _toggleAudio({ serviceId }) {
995 const service = this.one(serviceId);
996
997 this.actions.service.updateService({
998 serviceId,
999 serviceData: {
1000 isMuted: !service.isMuted,
1001 },
1002 redirect: false,
1003 });
1004 }
1005
1006 @action _toggleDarkMode({ serviceId }) {
1007 const service = this.one(serviceId);
1008
1009 this.actions.service.updateService({
1010 serviceId,
1011 serviceData: {
1012 isDarkModeEnabled: !service.isDarkModeEnabled,
1013 },
1014 redirect: false,
1015 });
1016 }
1017
1018 @action _openDevTools({ serviceId }) {
1019 const service = this.one(serviceId);
1020 if (service.isTodosService) {
1021 this.actions.todos.openDevTools();
1022 } else if (service.webview) {
1023 service.webview.openDevTools();
1024 }
1025 }
1026
1027 @action _openDevToolsForActiveService() {
1028 const service = this.active;
1029
1030 if (service) {
1031 this._openDevTools({ serviceId: service.id });
1032 } else {
1033 debug('No service is active');
1034 }
1035 }
1036
1037 @action _hibernate({ serviceId }) {
1038 const service = this.one(serviceId);
1039 if (!service.canHibernate) {
1040 return;
1041 }
1042
1043 debug(`Hibernate ${service.name}`);
1044
1045 service.isHibernationRequested = true;
1046 service.lastHibernated = Date.now();
1047 }
1048
1049 @action _awake({ serviceId, automatic }) {
1050 const now = Date.now();
1051 const service = this.one(serviceId);
1052 const automaticTag = automatic ? ' automatically ' : ' ';
1053 debug(
1054 `Waking up${automaticTag}from service hibernation for ${service.name}`,
1055 );
1056
1057 if (automatic) {
1058 // if this is an automatic wake up, use the wakeUpHibernationStrategy
1059 // which sets the lastUsed time to an offset from now rather than to now.
1060 // Also add an optional random splay to desync the wakeups and
1061 // potentially reduce load.
1062 //
1063 // offsetNow = now - (hibernationStrategy - wakeUpHibernationStrategy)
1064 //
1065 // if wUHS = hS = 60, offsetNow = now. hibernation again in 60 seconds.
1066 //
1067 // if wUHS = 20 and hS = 60, offsetNow = now - 40. hibernation again in
1068 // 20 seconds.
1069 //
1070 // possibly also include splay in wUHS before subtracting from hS.
1071 //
1072 const mainStrategy = this.stores.settings.all.app.hibernationStrategy;
1073 let strategy = this.stores.settings.all.app.wakeUpHibernationStrategy;
1074 debug(`wakeUpHibernationStrategy = ${strategy}`);
1075 debug(`hibernationStrategy = ${mainStrategy}`);
1076 if (!strategy || strategy < 1) {
1077 strategy = this.stores.settings.all.app.hibernationStrategy;
1078 }
1079 let splay = 0;
1080 // Add splay. This will keep the service awake a little longer.
1081 if (
1082 this.stores.settings.all.app.wakeUpHibernationSplay &&
1083 Math.random() >= 0.5
1084 ) {
1085 // Add 10 additional seconds 50% of the time.
1086 splay = 10;
1087 debug('Added splay');
1088 } else {
1089 debug('skipping splay');
1090 }
1091 // wake up again in strategy + splay seconds instead of mainStrategy seconds.
1092 service.lastUsed = now - ms(`${mainStrategy - (strategy + splay)}s`);
1093 } else {
1094 service.lastUsed = now;
1095 }
1096 debug(
1097 `Setting service.lastUsed to ${service.lastUsed} (${
1098 (now - service.lastUsed) / 1000
1099 }s ago)`,
1100 );
1101 service.isHibernationRequested = false;
1102 service.lastHibernated = null;
1103 }
1104
1105 @action _resetLastPollTimer({ serviceId = null }) {
1106 debug(
1107 `Reset last poll timer for ${
1108 serviceId ? `service: "${serviceId}"` : 'all services'
1109 }`,
1110 );
1111
1112 // eslint-disable-next-line unicorn/consistent-function-scoping
1113 const resetTimer = service => {
1114 service.lastPollAnswer = Date.now();
1115 service.lastPoll = Date.now();
1116 };
1117
1118 if (!serviceId) {
1119 for (const service of this.allDisplayed) resetTimer(service);
1120 } else {
1121 const service = this.one(serviceId);
1122 if (service) {
1123 resetTimer(service);
1124 }
1125 }
1126 }
1127
1128 // Reactions
1129 _focusServiceReaction() {
1130 const service = this.active;
1131 if (service) {
1132 this.actions.service.focusService({ serviceId: service.id });
1133 document.title = `Ferdium - ${service.name} ${
1134 service.dialogTitle ? ` - ${service.dialogTitle}` : ''
1135 } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`;
1136 } else {
1137 debug('No service is active');
1138 }
1139 }
1140
1141 _saveActiveService() {
1142 const service = this.active;
1143 if (service) {
1144 this.actions.settings.update({
1145 type: 'service',
1146 data: {
1147 activeService: service.id,
1148 },
1149 });
1150 } else {
1151 debug('No service is active');
1152 }
1153 }
1154
1155 _mapActiveServiceToServiceModelReaction() {
1156 const { activeService } = this.stores.settings.all.service;
1157 if (this.allDisplayed.length > 0) {
1158 this.allDisplayed.map(service =>
1159 Object.assign(service, {
1160 isActive: activeService
1161 ? activeService === service.id
1162 : this.allDisplayed[0].id === service.id,
1163 }),
1164 );
1165 }
1166 }
1167
1168 _getUnreadMessageCountReaction() {
1169 const { showMessageBadgeWhenMuted } = this.stores.settings.all.app;
1170 const { showMessageBadgesEvenWhenMuted } = this.stores.ui;
1171
1172 const unreadDirectMessageCount = this.allDisplayed
1173 .filter(
1174 s =>
1175 (showMessageBadgeWhenMuted || s.isNotificationEnabled) &&
1176 showMessageBadgesEvenWhenMuted &&
1177 s.isBadgeEnabled,
1178 )
1179 .map(s => s.unreadDirectMessageCount)
1180 .reduce((a, b) => a + b, 0);
1181
1182 const unreadIndirectMessageCount = this.allDisplayed
1183 .filter(
1184 s =>
1185 showMessageBadgeWhenMuted &&
1186 showMessageBadgesEvenWhenMuted &&
1187 s.isBadgeEnabled &&
1188 s.isIndirectMessageBadgeEnabled,
1189 )
1190 .map(s => s.unreadIndirectMessageCount)
1191 .reduce((a, b) => a + b, 0);
1192
1193 // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases
1194 if (showMessageBadgesEvenWhenMuted) {
1195 this.actions.app.setBadge({
1196 unreadDirectMessageCount,
1197 unreadIndirectMessageCount,
1198 });
1199 }
1200 }
1201
1202 _logoutReaction() {
1203 if (!this.stores.user.isLoggedIn) {
1204 this.actions.settings.remove({
1205 type: 'service',
1206 key: 'activeService',
1207 });
1208 this.allServicesRequest.invalidate().reset();
1209 }
1210 }
1211
1212 _handleMuteSettings() {
1213 const { enabled } = this;
1214 const { isAppMuted } = this.stores.settings.app;
1215
1216 for (const service of enabled) {
1217 const { isAttached } = service;
1218 const isMuted = isAppMuted || service.isMuted;
1219
1220 if (isAttached && service.webview) {
1221 service.webview.audioMuted = isMuted;
1222 }
1223 }
1224 }
1225
1226 _shareSettingsWithServiceProcess() {
1227 const settings = {
1228 ...this.stores.settings.app,
1229 isDarkThemeActive: this.stores.ui.isDarkThemeActive,
1230 };
1231 this.actions.service.sendIPCMessageToAllServices({
1232 channel: 'settings-update',
1233 args: settings,
1234 });
1235 }
1236
1237 _cleanUpTeamIdAndCustomUrl(recipeId, data) {
1238 const serviceData = data;
1239 const recipe = this.stores.recipes.one(recipeId);
1240
1241 if (!recipe) return;
1242
1243 if (
1244 recipe.hasTeamId &&
1245 recipe.hasCustomUrl &&
1246 data.team &&
1247 data.customUrl
1248 ) {
1249 delete serviceData.team;
1250 }
1251
1252 return serviceData;
1253 }
1254
1255 _checkForActiveService() {
1256 if (
1257 !this.stores.router.location ||
1258 this.stores.router.location.pathname.includes('auth/signup')
1259 ) {
1260 return;
1261 }
1262
1263 if (
1264 this.allDisplayed.findIndex(service => service.isActive) === -1 &&
1265 this.allDisplayed.length > 0
1266 ) {
1267 debug('No active service found, setting active service to index 0');
1268
1269 this._setActive({ serviceId: this.allDisplayed[0].id });
1270 }
1271 }
1272
1273 // Helper
1274 _initializeServiceRecipeInWebview(serviceId) {
1275 const service = this.one(serviceId);
1276
1277 if (service.webview) {
1278 // We need to completely clone the object, otherwise Electron won't be able to send the object via IPC
1279 const shareWithWebview = cleanseJSObject(service.shareWithWebview);
1280
1281 debug('Initialize recipe', service.recipe.id, service.name);
1282 service.webview.send(
1283 'initialize-recipe',
1284 {
1285 ...shareWithWebview,
1286 franzVersion: ferdiumVersion,
1287 },
1288 service.recipe,
1289 );
1290 }
1291 }
1292
1293 _initRecipePolling(serviceId) {
1294 const service = this.one(serviceId);
1295
1296 const delay = ms('2s');
1297
1298 if (service) {
1299 if (service.timer !== null) {
1300 clearTimeout(service.timer);
1301 }
1302
1303 const loop = () => {
1304 if (!service.webview) return;
1305
1306 service.webview.send('poll');
1307
1308 service.timer = setTimeout(loop, delay);
1309 service.lastPoll = Date.now();
1310 };
1311
1312 loop();
1313 }
1314 }
1315
1316 _wrapIndex(index, delta, size) {
1317 return (((index + delta) % size) + size) % size;
1318 }
1319}