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