From 8205acc5d574498cda78789fb924e60a8e049468 Mon Sep 17 00:00:00 2001 From: Francis Lachapelle Date: Wed, 29 Jul 2020 16:31:19 -0400 Subject: [PATCH] feat(web): support desktop notifications, add global inbox polling Fixes #1234, fixes #3382, fixes #4295 --- .../English.lproj/Localizable.strings | 1 + .../MailerUI/UIxMailFolderTemplate.wox | 2 +- UI/Templates/PreferencesUI/UIxPreferences.wox | 11 + .../js/Common/Alarm.service.js | 153 -------- .../js/Common/navController.js | 11 +- UI/WebServerResources/js/Common/utils.js | 2 +- .../js/Mailer/MailboxController.js | 5 + .../js/Preferences/Preferences.service.js | 351 +++++++++++++++++- .../js/Preferences/PreferencesController.js | 9 +- .../js/Scheduler/CalendarListController.js | 6 +- .../js/Scheduler/ComponentController.js | 12 +- 11 files changed, 394 insertions(+), 169 deletions(-) delete mode 100644 UI/WebServerResources/js/Common/Alarm.service.js diff --git a/UI/PreferencesUI/English.lproj/Localizable.strings b/UI/PreferencesUI/English.lproj/Localizable.strings index d113db5e3..5ee441147 100644 --- a/UI/PreferencesUI/English.lproj/Localizable.strings +++ b/UI/PreferencesUI/English.lproj/Localizable.strings @@ -323,6 +323,7 @@ "refreshview_every_20_minutes" = "Every 20 minutes"; "refreshview_every_30_minutes" = "Every 30 minutes"; "refreshview_once_per_hour" = "Once per hour"; +"Enable Desktop Notifications" = "Enable Desktop Notifications"; /* Return receipts */ "When I receive a request for a return receipt" = "When I receive a request for a return receipt"; diff --git a/UI/Templates/MailerUI/UIxMailFolderTemplate.wox b/UI/Templates/MailerUI/UIxMailFolderTemplate.wox index 1905aaec3..8019cb742 100644 --- a/UI/Templates/MailerUI/UIxMailFolderTemplate.wox +++ b/UI/Templates/MailerUI/UIxMailFolderTemplate.wox @@ -115,7 +115,7 @@ - + refresh diff --git a/UI/Templates/PreferencesUI/UIxPreferences.wox b/UI/Templates/PreferencesUI/UIxPreferences.wox index c8ff7826c..c6c962962 100644 --- a/UI/Templates/PreferencesUI/UIxPreferences.wox +++ b/UI/Templates/PreferencesUI/UIxPreferences.wox @@ -200,6 +200,17 @@ +
+ + + +
+
0) delay -= utc; - var d = new Date(alarmTime*1000); - //console.log ("now = " + now.toUTCString()); - //console.log ("next event " + url + " in " + delay + " seconds (on " + d.toUTCString() + ")"); - - var f = angular.bind(_this, Alarm.showAlarm, url); - - if (_this.currentAlarm) - _this.$timeout.cancel(_this.currentAlarm); - - _this.currentAlarm = _this.$timeout(f, delay*1000); - } - }); - }; - - /** - * @name showAlarm - * @desc Show the latest alarm using a toast - * @param url The URL of the calendar component for snoozing - */ - Alarm.showAlarm = function(url) { - var _this = this; - - this.$$resource.fetch(url, '?resetAlarm=yes').then(function(data) { - _this.$toast.show({ - position: 'top right', - hideDelay: 0, - template: [ - '', - '
', - '
', - '

{{ summary }}

', - '
', - ' ', - ' ', - ' ', - ' ', - l('5 minutes'), - ' ', - ' ', - l('10 minutes'), - ' ', - ' ', - l('15 minutes'), - ' ', - ' ', - l('30 minutes'), - ' ', - ' ', - l('45 minutes'), - ' ', - ' ', - l('1 hour'), - ' ', - ' ', - l('1 day'), - ' ', - ' ', - ' ', - ' ', - l('Snooze'), - ' ', - ' ', - l('Close'), - ' ', - '
', - '
', - '
', - '
' - ].join(''), - locals: { - url: url - }, - controller: AlarmController - }); - - /** - * @ngInject - */ - AlarmController.$inject = ['scope', '$mdToast', 'url']; - function AlarmController(scope, $mdToast, url) { - scope.summary = data.summary; - scope.reminder = '10'; - scope.close = function() { - $mdToast.hide(); - }; - scope.snooze = function() { - _this.$$resource.fetch(url, 'view?snoozeAlarm=' + scope.reminder); - $mdToast.hide(); - }; - } - }); - }; - - /** - * @memberof Alarm - * @desc The factory we'll register as Alarm in the Angular module SOGo.Common - * @ngInject - */ - AlarmService.$inject = ['$timeout', 'sgSettings', 'Resource', '$mdToast']; - function AlarmService($timeout, Settings, Resource, $mdToast) { - angular.extend(Alarm, { - $timeout: $timeout, - $$resource: new Resource(Settings.activeUser('folderURL') + 'Calendar', Settings.activeUser()), - $toast: $mdToast - }); - - return Alarm; // return constructor - } - - /* Factory registration in Angular module */ - angular - .module('SOGo.Common') - .factory('Alarm', AlarmService); - -})(); diff --git a/UI/WebServerResources/js/Common/navController.js b/UI/WebServerResources/js/Common/navController.js index 73c7ea504..d1cadd48e 100644 --- a/UI/WebServerResources/js/Common/navController.js +++ b/UI/WebServerResources/js/Common/navController.js @@ -7,8 +7,8 @@ /** * @ngInject */ - navController.$inject = ['$rootScope', '$scope', '$timeout', '$interval', '$http', '$window', '$mdSidenav', '$mdToast', '$mdMedia', '$log', 'sgConstant', 'sgSettings', 'Resource', 'Alarm']; - function navController($rootScope, $scope, $timeout, $interval, $http, $window, $mdSidenav, $mdToast, $mdMedia, $log, sgConstant, sgSettings, Resource, Alarm) { + navController.$inject = ['$rootScope', '$scope', '$timeout', '$interval', '$http', '$window', '$mdSidenav', '$mdToast', '$mdMedia', '$log', 'sgConstant', 'sgSettings', 'Resource', 'Preferences']; + function navController($rootScope, $scope, $timeout, $interval, $http, $window, $mdSidenav, $mdToast, $mdMedia, $log, sgConstant, sgSettings, Resource, Preferences) { var resource = new Resource(sgSettings.baseURL(), sgSettings.activeUser()); this.$onInit = function() { @@ -52,7 +52,12 @@ if (sgSettings.activeUser('path').calendar) { // Fetch Calendar alarms - Alarm.getAlarms(); + Preferences.getAlarms(); + } + + if (sgSettings.activeUser('path').mail) { + // Poll inbox for new messages + Preferences.pollInbox(); } }; diff --git a/UI/WebServerResources/js/Common/utils.js b/UI/WebServerResources/js/Common/utils.js index aed89c44c..7273a22fa 100644 --- a/UI/WebServerResources/js/Common/utils.js +++ b/UI/WebServerResources/js/Common/utils.js @@ -475,7 +475,7 @@ Date.prototype.getDayString = function() { return newString; }; -// MMHH +// HH00 Date.prototype.getHourString = function() { var newString = this.getHours() + '00'; if (newString.length == 3) diff --git a/UI/WebServerResources/js/Mailer/MailboxController.js b/UI/WebServerResources/js/Mailer/MailboxController.js index d9ca2e145..8d5410ee2 100644 --- a/UI/WebServerResources/js/Mailer/MailboxController.js +++ b/UI/WebServerResources/js/Mailer/MailboxController.js @@ -151,6 +151,11 @@ return Mailbox.$query.asc; }; + this.refresh = function () { + Preferences.pollInbox(); + this.selectedFolder.$filter(); + }; + this.searchMode = function($event) { vm.mode.search = true; focus('search'); diff --git a/UI/WebServerResources/js/Preferences/Preferences.service.js b/UI/WebServerResources/js/Preferences/Preferences.service.js index d1efa4206..4f538fd3d 100644 --- a/UI/WebServerResources/js/Preferences/Preferences.service.js +++ b/UI/WebServerResources/js/Preferences/Preferences.service.js @@ -10,6 +10,12 @@ function Preferences() { var _this = this, defaultsElement, settingsElement, data; + this.nextAlarm = null; + this.nextInboxPoll = null; + this.currentToast = Preferences.$q.when(true); // Show only one toast at a time (see https://github.com/angular/material/issues/2799) + this.lastUid = null; + this.notifications = {}; + this.defaults = {}; this.settings = {Mail: {}}; @@ -197,13 +203,16 @@ * @desc The factory we'll use to register with Angular * @returns the Preferences constructor */ - Preferences.$factory = ['$document', '$q', '$timeout', '$log', '$mdDateLocale', 'sgSettings', 'Gravatar', 'Resource', 'User', function($document, $q, $timeout, $log, $mdDateLocaleProvider, Settings, Gravatar, Resource, User) { + Preferences.$factory = ['$window', '$document', '$q', '$timeout', '$log', '$state', '$mdDateLocale', '$mdToast', 'sgSettings', 'Gravatar', 'Resource', 'User', function($window, $document, $q, $timeout, $log, $state, $mdDateLocaleProvider, $mdToast, Settings, Gravatar, Resource, User) { angular.extend(Preferences, { + $window: $window, $document: $document, $q: $q, $timeout: $timeout, $log: $log, + $state: $state, $mdDateLocaleProvider: $mdDateLocaleProvider, + $toast: $mdToast, $gravatar: Gravatar, $$resource: new Resource(Settings.activeUser('folderURL'), Settings.activeUser()), $resourcesURL: Settings.resourcesURL(), @@ -283,6 +292,346 @@ } }; + /** + * @function supportsNotifications + * @memberof Preferences.prototype + * @desc Check if the browser supports the Notifications API + * @returns true if the browser is compatible + * @see {@link https://notifications.spec.whatwg.org/|Notifications API} + */ + Preferences.prototype.supportsNotifications = function () { + if (typeof Notification === 'undefined') { + Preferences.$log.warn("Notifications are not available for your browser."); + return false; + } + return true; + }; + + /** + * @function authorizeNotifications + * @memberof Preferences.prototype + * @desc Request authorization to send notifications + */ + Preferences.prototype.authorizeNotifications = function () { + if (this.supportsNotifications()) { + Notification.requestPermission(function (permission) { + return permission; + }); + } + }; + + /** + * @function createNotification + * @memberof Preferences.prototype + * @desc Display a HTML5 notification + * @param {string} id - a unique identifier + * @param {string} title + * @param {object} config - parameters of the notification (body, icon, onClick) + */ + Preferences.prototype.createNotification = function (id, title, config) { + var _this = this, + params = _.pick(config, ['body', 'icon']); + if (this.supportsNotifications ()) { + params.tag = id; + params.lang = ''; + params.dir = 'auto'; + this.notifications[id] = new Notification(title, params); + this.notifications[id].onclick = function () { + config.onClick(); + _this.notifications[id].close(); + }; + } + }; + + /** + * @function viewInboxMessage + * @memberof Preferences.prototype + * @desc Go to the specified message of the main account's inbox + * @param {string} uid - the message UID + */ + Preferences.prototype.viewInboxMessage = function(uid) { + if (Preferences.$state.get('mail.account')) { + // Currently in Mail module -- view message + Preferences.$state.go('mail.account.mailbox.message', { accountId: 0, mailboxId: 'INBOX', messageId: uid }); + } + else { + // On a different module -- reload page + Preferences.$window.location = Preferences.$$resource.path('Mail', 'view#!/Mail/0/INBOX/' + uid); + } + }; + + /** + * @function pollInbox + * @memberof Preferences.prototype + * @desc Poll server for new messages in main account's inbox, display notifications or toasts + */ + Preferences.prototype.pollInbox = function() { + var _this = this, params; + + params = { + sortingAttributes: { + sort: 'arrival', + asc: 0, + noHeaders: 0, + dry: 1 + }, + filters: [ + { + searchBy: 'flags', + searchInput: 'unseen' + } + ] + }; + + if (this.nextInboxPoll) + Preferences.$timeout.cancel(this.nextInboxPoll); + + Preferences.$$resource.post('Mail', '0/folderINBOX/view', params).then(function(data) { + var uids = data.uids; + var uidHeaderIndex = data.headers[0].indexOf('uid'); + var fromHeaderIndex = data.headers[0].indexOf('From'); + var subjectHeaderIndex = data.headers[0].indexOf('Subject'); + if (data.threaded) { + data.uids.splice(0, 1); + uids = _.map(data.uids, 0); + } + if (_this.lastUid) { + _.find(uids, function (uid, index) { + var headers, id, href, toast; + if (uid > _this.lastUid) { + // New unseen message + Preferences.$log.debug('Show notification for message ' + uid); + headers = _.find(data.headers, function(h) { + return h[uidHeaderIndex] == uid; + }); + if (_this.defaults.SOGoDesktopNotifications) { + id = 'mail-inbox-' + uid; + href = Preferences.$state.href('mail.account.mailbox.message', { accountId: 0, mailboxId: 'INBOX', messageId: uid }); + _this.createNotification(id, headers[subjectHeaderIndex], { + body: headers[fromHeaderIndex][0].name || headers[fromHeaderIndex][0].email, + icon: '/SOGo.woa/WebServerResources/img/email-256px.png', + onClick: function () { + _this.viewInboxMessage(uid); + } + }); + } + else { + toast = { + template: [ + '', + '
', + '
', + ' email', + '
', + headers[subjectHeaderIndex], + '
', + headers[fromHeaderIndex][0].name || headers[fromHeaderIndex][0].email, + '
', + '
', + '
', + ' ', + l('View'), + ' ', + '
', + '
', + '
' + ].join(''), + position: 'top right', + hideDelay: 5000, + controller: toastController + }; + _this.currentToast = _this.currentToast.then(function () { + return Preferences.$toast.show(toast) + .then(function(response) { + if (response === 'ok') { + _this.viewInboxMessage(uid); + } + }); + }); + } + return false; // Continue to next unseen message + } + else { + return true; // No more new messages + } + }); + if (uids[0] > _this.lastUid) { + _this.lastUid = uids[0]; + } + } else { + _this.lastUid = uids[0]; + } + }).finally(function () { + var refreshViewCheck = _this.defaults.SOGoRefreshViewCheck; + if (refreshViewCheck && refreshViewCheck != 'manually') + _this.nextInboxPoll = Preferences.$timeout(angular.bind(_this, _this.pollInbox), refreshViewCheck.timeInterval()*1000); + }); + + /** + * @ngInject + */ + toastController.$inject = ['scope', '$mdToast']; + function toastController (scope, $mdToast) { + scope.close = function() { + $mdToast.hide('ok'); + }; + } + }; + + /** + * @function getAlarms + * @memberof Preferences.prototype + * @desc Fetch the list of alarms from the server and schedule the last one + */ + Preferences.prototype.getAlarms = function() { + var _this = this; + var now = new Date(); + var browserTime = Math.floor(now.getTime()/1000); + + Preferences.$$resource.fetch('Calendar', 'alarmslist?browserTime=' + browserTime).then(function(data) { + var alarms = data.alarms.sort(function reverseSortByAlarmTime(a, b) { + var x = parseInt(a[2]); + var y = parseInt(b[2]); + return (y - x); + }); + if (alarms.length > 0) { + var next = alarms.pop(); + var now = new Date(); + var utc = Math.floor(now.getTime()/1000); + var url = next[0] + '/' + next[1]; + var alarmTime = parseInt(next[2]); + var delay = alarmTime; + if (alarmTime > 0) delay -= utc; + var d = new Date(alarmTime*1000); + //console.log ("now = " + now.toUTCString()); + //console.log ("next event " + url + " in " + delay + " seconds (on " + d.toUTCString() + ")"); + + var f = angular.bind(_this, _this.showAlarm, url); + + if (_this.nextAlarm) + Preferences.$timeout.cancel(_this.nextAlarm); + + _this.nextAlarm = Preferences.$timeout(f, delay*1000); + } + }); + }; + + /** + * @function showAlarm + * @memberof Preferences.prototype + * @desc Show the latest alarm using a notification and a toast + * @param url The URL of the calendar component for snoozing + */ + Preferences.prototype.showAlarm = function(url) { + var _this = this; + + Preferences.$$resource.fetch('Calendar/' + url, '?resetAlarm=yes').then(function(data) { + var today = new Date().beginOfDay(), + day = data.startDate.split(/T/)[0].asDate(), + period = [], + id; + if (day.getTime() != today.getTime() || data.localizedStartDate != data.localizedEndDate) { + period.push(data.localizedStartDate); + } + if (!data.isAllDay) { + period.push(data.localizedStartTime); + period.push('-'); + } + if (data.localizedStartDate != data.localizedEndDate) { + period.push(data.localizedEndDate); + } + if (!data.isAllDay) { + period.push(data.localizedEndTime); + } + if (_this.defaults.SOGoDesktopNotifications) { + id = 'calendar-' + data.id; + _this.createNotification(id, data.summary, { + body: period.join(' '), + icon: '/SOGo.woa/WebServerResources/img/event-256px.png', + onClick: function () { + if (Preferences.$state.get('calendars.view')) { + // Currently in Calendar module -- go to event's day + Preferences.$state.go('calendars.view', { view: 'day', day: day.getDayString()}); + } + else { + // On a different module -- reload page + Preferences.$window.location = Preferences.$$resource.path('Calendar', 'view#!/calendar/day/' + day.getDayString()); + } + } + }); + } + _this.currentToast = _this.currentToast.then(function () { + return Preferences.$toast.show({ + position: 'top right', + hideDelay: 0, + template: [ + '', + '
', + '
', + '

{{ summary }}

', + '
', + ' ', + ' ', + ' ', + ' ', + l('5 minutes'), + ' ', + ' ', + l('10 minutes'), + ' ', + ' ', + l('15 minutes'), + ' ', + ' ', + l('30 minutes'), + ' ', + ' ', + l('45 minutes'), + ' ', + ' ', + l('1 hour'), + ' ', + ' ', + l('1 day'), + ' ', + ' ', + ' ', + ' ', + l('Snooze'), + ' ', + ' ', + l('Close'), + ' ', + '
', + '
', + '
', + '
' + ].join(''), + locals: { + url: url + }, + controller: AlarmController + }); + }); + + /** + * @ngInject + */ + AlarmController.$inject = ['scope', 'url']; + function AlarmController(scope, url) { + scope.summary = data.summary; + scope.reminder = '10'; + scope.close = function() { + Preferences.$toast.hide(); + }; + scope.snooze = function() { + Preferences.$$resource.fetch(url, 'view?snoozeAlarm=' + scope.reminder); + Preferences.$toast.hide(); + }; + } + }); + }; + /** * @function $save * @memberof Preferences.prototype diff --git a/UI/WebServerResources/js/Preferences/PreferencesController.js b/UI/WebServerResources/js/Preferences/PreferencesController.js index c322c4f5d..6f3dff3dc 100644 --- a/UI/WebServerResources/js/Preferences/PreferencesController.js +++ b/UI/WebServerResources/js/Preferences/PreferencesController.js @@ -55,6 +55,11 @@ }); }; + this.onDesktopNotificationsChange = function() { + if (this.preferences.defaults.SOGoDesktopNotifications) + this.preferences.authorizeNotifications(); + }; + this.resetContactsCategories = function(form) { this.preferences.defaults.SOGoContactsCategories = $window.defaultContactsCategories; form.$setDirty(); @@ -466,7 +471,9 @@ if (this.passwords.newPasswordConfirmation && this.passwords.newPasswordConfirmation.length && this.passwords.newPassword != this.passwords.newPasswordConfirmation) { form.newPasswordConfirmation.$setValidity('newPasswordMismatch', false); - } else { + return false; + } + else { form.newPasswordConfirmation.$setValidity('newPasswordMismatch', true); } if (this.passwords.newPassword && this.passwords.newPassword.length > 0 && diff --git a/UI/WebServerResources/js/Scheduler/CalendarListController.js b/UI/WebServerResources/js/Scheduler/CalendarListController.js index c2d621cb0..3d2ee8bd0 100644 --- a/UI/WebServerResources/js/Scheduler/CalendarListController.js +++ b/UI/WebServerResources/js/Scheduler/CalendarListController.js @@ -6,8 +6,8 @@ /** * @ngInject */ - CalendarListController.$inject = ['$rootScope', '$scope', '$q', '$timeout', '$state', '$mdDialog', 'sgHotkeys', 'sgFocus', 'Dialog', 'Preferences', 'CalendarSettings', 'Calendar', 'Component', 'Alarm']; - function CalendarListController($rootScope, $scope, $q, $timeout, $state, $mdDialog, sgHotkeys, focus, Dialog, Preferences, CalendarSettings, Calendar, Component, Alarm) { + CalendarListController.$inject = ['$rootScope', '$scope', '$q', '$timeout', '$state', '$mdDialog', 'sgHotkeys', 'sgFocus', 'Dialog', 'Preferences', 'CalendarSettings', 'Calendar', 'Component']; + function CalendarListController($rootScope, $scope, $q, $timeout, $state, $mdDialog, sgHotkeys, focus, Dialog, Preferences, CalendarSettings, Calendar, Component) { var vm = this, hotkeys = [], type, sortLabels; sortLabels = { @@ -263,7 +263,7 @@ // Immediately perform the adjustments component.$adjust(params).then(function() { $rootScope.$emit('calendars:list'); - Alarm.getAlarms(); + Preferences.getAlarms(); }, function(response) { onComponentAdjustError(response, component, params); }).finally(function() { diff --git a/UI/WebServerResources/js/Scheduler/ComponentController.js b/UI/WebServerResources/js/Scheduler/ComponentController.js index 87a97d84c..21f9771f2 100644 --- a/UI/WebServerResources/js/Scheduler/ComponentController.js +++ b/UI/WebServerResources/js/Scheduler/ComponentController.js @@ -6,8 +6,8 @@ /** * @ngInject */ - ComponentController.$inject = ['$rootScope', '$scope', '$q', '$mdDialog', 'Calendar', 'Component', 'AddressBook', 'Alarm', 'Account', 'stateComponent']; - function ComponentController($rootScope, $scope, $q, $mdDialog, Calendar, Component, AddressBook, Alarm, Account, stateComponent) { + ComponentController.$inject = ['$rootScope', '$scope', '$q', '$mdDialog', 'Preferences', 'Calendar', 'Component', 'AddressBook', 'Account', 'stateComponent']; + function ComponentController($rootScope, $scope, $q, $mdDialog, Preferences, Calendar, Component, AddressBook, Account, stateComponent) { var vm = this, component; this.$onInit = function () { @@ -119,7 +119,7 @@ c.$reply().then(function() { $rootScope.$emit('calendars:list'); - Alarm.getAlarms(); + Preferences.getAlarms(); $mdDialog.hide(); }); }; @@ -205,8 +205,8 @@ /** * @ngInject */ - ComponentEditorController.$inject = ['$rootScope', '$scope', '$log', '$timeout', '$window', '$element', '$mdDialog', '$mdToast', 'sgFocus', 'User', 'CalendarSettings', 'Calendar', 'Component', 'Attendees', 'AddressBook', 'Card', 'Alarm', 'Preferences', 'stateComponent']; - function ComponentEditorController($rootScope, $scope, $log, $timeout, $window, $element, $mdDialog, $mdToast, focus, User, CalendarSettings, Calendar, Component, Attendees, AddressBook, Card, Alarm, Preferences, stateComponent) { + ComponentEditorController.$inject = ['$rootScope', '$scope', '$log', '$timeout', '$window', '$element', '$mdDialog', '$mdToast', 'sgFocus', 'User', 'CalendarSettings', 'Calendar', 'Component', 'Attendees', 'AddressBook', 'Card', 'Preferences', 'stateComponent']; + function ComponentEditorController($rootScope, $scope, $log, $timeout, $window, $element, $mdDialog, $mdToast, focus, User, CalendarSettings, Calendar, Component, Attendees, AddressBook, Card, Preferences, stateComponent) { var vm = this, component, oldStartDate, oldEndDate, oldDueDate, dayStartTime, dayEndTime; this.$onInit = function () { @@ -470,7 +470,7 @@ this.component.$save(options) .then(function(data) { $rootScope.$emit('calendars:list'); - Alarm.getAlarms(); + Preferences.getAlarms(); $mdDialog.hide(); }, function(response) { if (response.status == CalendarSettings.ConflictHTTPErrorCode &&