diff --git a/.htaccess b/.htaccess index eb8b71840..8b8b08db7 100644 --- a/.htaccess +++ b/.htaccess @@ -26,9 +26,8 @@ RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301] Header set X-Content-Type-Options "nosniff" Header set X-Frame-Options "DENY" Header set X-XSS-Protection "1; mode=block" - + Header set Service-Worker-Allowed "/" - RewriteCond %{HTTP:Accept-encoding} br RewriteCond "%{REQUEST_FILENAME}\.br" -s RewriteRule "^(.+)" "$1\.br" [L,T=text/javascript,QSA] diff --git a/README.md b/README.md index a36717760..ad90a699e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ This fork of RainLoop has the following changes: * Split Admin specific JavaScript code from User code * JSON reviver * Better memory garbage collection management +* Added serviceworker for Notifications ### Removal of old JavaScript @@ -107,24 +108,25 @@ Things might work in Edge 18, Firefox 50-62 and Chrome 54-68 due to one polyfill RainLoop 1.14 vs SnappyMail -|js/* |RainLoop |Snappy | -|----------- |--------: |--------: | -|admin.js |2.130.942 | 652.764 | -|app.js |4.184.455 |2.319.847 | -|boot.js | 671.522 | 5.285 | -|libs.js | 647.614 | 235.271 | -|polyfills.js | 325.834 | 0 | -|TOTAL |7.960.367 |3.213.167 | +|js/* |RainLoop |Snappy | +|--------------- |--------: |--------: | +|admin.js |2.130.942 | 652.023 | +|app.js |4.184.455 |2.310.715 | +|boot.js | 671.522 | 5.285 | +|libs.js | 647.614 | 235.271 | +|polyfills.js | 325.834 | 0 | +|serviceworker.js | 0 | 285 | +|TOTAL |7.960.367 |3.203.579 | |js/min/* |RainLoop |Snappy |Rain gzip |gzip |brotli | |--------------- |--------: |--------: |--------: |--------: |--------: | -|admin.min.js | 252.147 | 90.573 | 73.657 | 23.735 | 20.767 | -|app.min.js | 511.202 | 312.734 |140.462 | 83.425 | 67.822 | +|admin.min.js | 252.147 | 90.470 | 73.657 | 23.707 | 20.738 | +|app.min.js | 511.202 | 310.166 |140.462 | 83.178 | 67.672 | |boot.min.js | 66.007 | 2.918 | 22.567 | 1.500 | 1.275 | |libs.min.js | 572.545 | 130.767 |176.720 | 47.288 | 42.043 | |polyfills.min.js | 32.452 | 0 | 11.312 | 0 | 0 | -|TOTAL |1.434.353 | 536.992 |424.718 |155.948 |131.907 | -|TOTAL (no admin) |1.182.206 | 446.419 |351.061 |132.213 |111.140 | +|TOTAL |1.434.353 | 534.321 |424.718 |155.673 |131.728 | +|TOTAL (no admin) |1.182.206 | 443.851 |351.061 |131.966 |110.990 | For a user its around 62% smaller and faster than traditional RainLoop. diff --git a/dev/Common/Audio.js b/dev/Common/Audio.js index 91a35c7db..77fc4258a 100644 --- a/dev/Common/Audio.js +++ b/dev/Common/Audio.js @@ -1,128 +1,128 @@ import * as Links from 'Common/Links'; -class Audio { - notificator = null; - player = null; +let notificator = null, + player = null, + canPlay = type => player && !!player.canPlayType(type).replace('no', ''), - supported = false; - supportedMp3 = false; - supportedOgg = false; - supportedWav = false; - supportedNotification = false; + audioCtx = AudioContext || window.webkitAudioContext, - constructor() { - this.player = this.createNewObject(); - - // Safari can't play without user interaction - this.supported = !!this.player && !!this.player.play; - if (this.supported && this.player && this.player.canPlayType) { - this.supportedMp3 = !!this.player.canPlayType('audio/mpeg;').replace(/no/, ''); - this.supportedWav = !!this.player.canPlayType('audio/wav; codecs="1"').replace(/no/, ''); - this.supportedOgg = !!this.player.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''); - this.supportedNotification = this.supported && this.supportedMp3; + play = (url, name) => { + if (player) { + player.src = url; + player.play(); + name = name.trim(); + dispatchEvent(new CustomEvent('audio.start', {detail:name.replace(/\.([a-z0-9]{3})$/, '') || 'audio'})); } + }, - if (!this.player || (!this.supportedMp3 && !this.supportedOgg && !this.supportedWav)) { - this.supported = false; - this.supportedMp3 = false; - this.supportedOgg = false; - this.supportedWav = false; - this.supportedNotification = false; - } - - if (this.supported && this.player) { - const stopFn = () => this.stop(); - - this.player.addEventListener('ended', stopFn); - this.player.addEventListener('error', stopFn); - - addEventListener('audio.api.stop', stopFn); - } - } - - createNewObject() { + createNewObject = () => { try { - const player = window.Audio ? new Audio() : null; - if (player && player.canPlayType && player.pause && player.play) { + const player = new Audio; + if (player.canPlayType && player.pause && player.play) { player.preload = 'none'; player.loop = false; player.autoplay = false; player.muted = false; + return player; } - - return player; - } catch (e) {} // eslint-disable-line no-empty - + } catch (e) { + console.error(e); + } return null; + }, + + // The AudioContext is not allowed to start. + // It must be resumed (or created) after a user gesture on the page. https://goo.gl/7K7WLu + // Setup listeners to attempt an unlock + unlockEvents = [ + 'click','dblclick', + 'contextmenu', + 'auxclick', + 'mousedown','mouseup', + 'pointerup', + 'touchstart','touchend', + 'keydown','keyup' + ], + unlock = () => { + if (audioCtx) { + console.log('AudioContext ' + audioCtx.state); + audioCtx.resume(); + } + unlockEvents.forEach(type => document.removeEventListener(type, unlock, true)); +// setTimeout(()=>Audio.playNotification(1),1); + }; + +if (audioCtx) { + audioCtx = audioCtx ? new audioCtx : null; + audioCtx.onstatechange = unlock; +} +unlockEvents.forEach(type => document.addEventListener(type, unlock, true)); + +/** + * Browsers can't play without user interaction + */ + +const SMAudio = new class { + supported = false; + supportedMp3 = false; + supportedOgg = false; + supportedWav = false; + + constructor() { + player || (player = createNewObject()); + + this.supported = !!player; + if (player) { + this.supportedMp3 = !!canPlay('audio/mpeg;'); + this.supportedWav = !!canPlay('audio/wav; codecs="1"'); + this.supportedOgg = !!canPlay('audio/ogg; codecs="vorbis"'); + + const stopFn = () => this.pause(); + player.addEventListener('ended', stopFn); + player.addEventListener('error', stopFn); + addEventListener('audio.api.stop', stopFn); + } } paused() { - return this.supported ? !!this.player.paused : true; + return !player || player.paused; } stop() { - if (this.supported && this.player.pause) { - this.player.pause(); - } - - dispatchEvent(new CustomEvent('audio.stop')); + this.pause(); } pause() { - this.stop(); - } - - clearName(name = '', ext = '') { - name = name.trim(); - if (ext && '.' + ext === name.toLowerCase().substr((ext.length + 1) * -1)) { - name = name.substr(0, name.length - 4).trim(); - } - - return name || 'audio'; + player && player.pause(); + dispatchEvent(new CustomEvent('audio.stop')); } playMp3(url, name) { - if (this.supported && this.supportedMp3) { - this.player.src = url; - this.player.play(); - - dispatchEvent(new CustomEvent('audio.start', {detail:this.clearName(name, 'mp3')})); - } + this.supportedMp3 && play(url, name); } playOgg(url, name) { - if (this.supported && this.supportedOgg) { - this.player.src = url; - this.player.play(); - - name = this.clearName(name, 'oga'); - name = this.clearName(name, 'ogg'); - - dispatchEvent(new CustomEvent('audio.start', {detail:name})); - } + this.supportedOgg && play(url, name); } playWav(url, name) { - if (this.supported && this.supportedWav) { - this.player.src = url; - this.player.play(); - - dispatchEvent(new CustomEvent('audio.start', {detail:this.clearName(name, 'wav')})); - } + this.supportedWav && play(url, name); } - playNotification() { - if (this.supported && this.supportedMp3) { - if (!this.notificator) { - this.notificator = this.createNewObject(); - this.notificator.src = Links.sound('new-mail.mp3'); + playNotification(silent) { + if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) { + if (!notificator) { + notificator = createNewObject(); + notificator.src = Links.sound('new-mail.'+ (this.supportedMp3 ? 'mp3' : 'ogg')); } - - if (this.notificator && this.notificator.play) { - this.notificator.play(); + if (notificator) { + notificator.volume = silent ? 0.01 : 1; + notificator.play(); } + } else { + console.log('No audio: ' + audioCtx.state); } } -} +}; -export default new Audio(); +export default SMAudio; diff --git a/dev/Common/Enums.js b/dev/Common/Enums.js index 2505e0c99..1ccd8ea4c 100644 --- a/dev/Common/Enums.js +++ b/dev/Common/Enums.js @@ -197,16 +197,6 @@ export const MessageSelectAction = { Unflagged: 6 }; -/** - * @enum {number} - */ -export const DesktopNotification = { - Allowed: 0, - NotAllowed: 1, - Denied: 2, - NotSupported: 9 -}; - /** * @enum {number} */ diff --git a/dev/Settings/User/General.js b/dev/Settings/User/General.js index ddb13db3c..df9a994c7 100644 --- a/dev/Settings/User/General.js +++ b/dev/Settings/User/General.js @@ -30,11 +30,9 @@ class GeneralUserSettings { this.layout = SettingsStore.layout; this.usePreviewPane = SettingsStore.usePreviewPane; - this.soundNotificationIsSupported = NotificationStore.soundNotificationIsSupported; this.enableSoundNotification = NotificationStore.enableSoundNotification; this.enableDesktopNotification = NotificationStore.enableDesktopNotification; - this.isDesktopNotificationSupported = NotificationStore.isDesktopNotificationSupported; this.isDesktopNotificationDenied = NotificationStore.isDesktopNotificationDenied; this.showImages = SettingsStore.showImages; @@ -96,6 +94,10 @@ class GeneralUserSettings { NotificationStore.playSoundNotification(true); } + testSystemNotification() { + NotificationStore.displayDesktopNotification('SnappyMail', 'Test notification', { 'Folder': '', 'Uid': '' }); + } + onBuild() { setTimeout(() => { const f0 = settingsSaveHelperSimpleFunction(this.editorDefaultTypeTrigger, this), @@ -157,10 +159,6 @@ class GeneralUserSettings { }, 50); } - onShow() { - this.enableDesktopNotification.valueHasMutated(); - } - selectLanguage() { showScreenPopup(require('View/Popup/Languages'), [this.language, this.languages(), LanguageStore.userLanguage()]); } diff --git a/dev/Stores/User/Message.js b/dev/Stores/User/Message.js index 906d856b3..4cb62277c 100644 --- a/dev/Stores/User/Message.js +++ b/dev/Stores/User/Message.js @@ -16,7 +16,7 @@ import { clearNewMessageCache } from 'Common/Cache'; -import { mailBox, notificationMailIcon } from 'Common/Links'; +import { mailBox } from 'Common/Links'; import { i18n, getNotification } from 'Common/Translator'; import { EmailCollectionModel } from 'Model/EmailCollection'; @@ -245,7 +245,6 @@ class MessageUserStore { const len = newMessages.length; if (3 < len) { NotificationStore.displayDesktopNotification( - notificationMailIcon(), AccountStore.email(), i18n('MESSAGE_LIST/NEW_MESSAGE_NOTIFICATION', { 'COUNT': len @@ -255,7 +254,6 @@ class MessageUserStore { } else { newMessages.forEach(item => { NotificationStore.displayDesktopNotification( - notificationMailIcon(), EmailCollectionModel.reviveFromJson(item.From).toString(), item.Subject, { 'Folder': item.Folder, 'Uid': item.Uid } diff --git a/dev/Stores/User/Notification.js b/dev/Stores/User/Notification.js index 0ee2c90c4..c4075337b 100644 --- a/dev/Stores/User/Notification.js +++ b/dev/Stores/User/Notification.js @@ -1,154 +1,96 @@ import ko from 'ko'; -import { DesktopNotification } from 'Common/Enums'; import Audio from 'Common/Audio'; +import * as Links from 'Common/Links'; +/** + * Might not work due to the new ServiceWorkerRegistration.showNotification + */ +const HTML5Notification = window.Notification ? Notification : null, + HTML5NotificationStatus = () => (HTML5Notification && HTML5Notification.permission) || 'denied', + dispatchMessage = data => { + focus(); + if (data.Folder && data.Uid) { + dispatchEvent(new CustomEvent('mailbox.message.show', {detail:data})); + } + }; + +let DesktopNotifications = false, + WorkerNotifications = navigator.serviceWorker; + +// Are Notifications supported in the service worker? +if (WorkerNotifications && ServiceWorkerRegistration && ServiceWorkerRegistration.prototype.showNotification) { + console.log('ServiceWorker supported'); + /* Listen for close requests from the ServiceWorker */ + WorkerNotifications.addEventListener('message', event => { + const obj = JSON.parse(event.data); + obj && 'notificationclick' === obj.action && dispatchMessage(obj.data); + }); +} else { + WorkerNotifications = null; + console.log('WorkerNotifications not supported'); +} class NotificationUserStore { constructor() { this.enableSoundNotification = ko.observable(false); - this.soundNotificationIsSupported = ko.observable(false); - this.allowDesktopNotification = ko.observable(false); + this.enableDesktopNotification = ko.observable(false)/*.extend({ notify: 'always' })*/; - this.desktopNotificationPermissions = ko - .computed(() => { - this.allowDesktopNotification(); + this.isDesktopNotificationDenied = ko.observable('denied' === HTML5NotificationStatus()); - let result = DesktopNotification.NotSupported; - - const NotificationClass = this.notificationClass(); - if (NotificationClass && NotificationClass.permission) { - switch (NotificationClass.permission.toLowerCase()) { - case 'granted': - result = DesktopNotification.Allowed; - break; - case 'denied': - result = DesktopNotification.Denied; - break; - case 'default': - result = DesktopNotification.NotAllowed; - break; - // no default - } - } else if (window.webkitNotifications && window.webkitNotifications.checkPermission) { - result = window.webkitNotifications.checkPermission(); - } - - return result; - }) - .extend({ notify: 'always' }); - - this.enableDesktopNotification = ko - .computed({ - read: () => - this.allowDesktopNotification() && DesktopNotification.Allowed === this.desktopNotificationPermissions(), - write: (value) => { - if (value) { - const NotificationClass = this.notificationClass(), - permission = this.desktopNotificationPermissions(); - - if (NotificationClass && DesktopNotification.Allowed === permission) { - this.allowDesktopNotification(true); - } else if (NotificationClass && DesktopNotification.NotAllowed === permission) { - NotificationClass.requestPermission(() => { - this.allowDesktopNotification.valueHasMutated(); - - if (DesktopNotification.Allowed === this.desktopNotificationPermissions()) { - if (this.allowDesktopNotification()) { - this.allowDesktopNotification.valueHasMutated(); - } else { - this.allowDesktopNotification(true); - } - } else { - if (this.allowDesktopNotification()) { - this.allowDesktopNotification(false); - } else { - this.allowDesktopNotification.valueHasMutated(); - } - } - }); - } else { - this.allowDesktopNotification(false); - } - } else { - this.allowDesktopNotification(false); - } - } - }) - .extend({ notify: 'always' }); - - if (!this.enableDesktopNotification.valueHasMutated) { - this.enableDesktopNotification.valueHasMutated = () => { - this.allowDesktopNotification.valueHasMutated(); - }; - } - - this.isDesktopNotificationSupported = ko.computed( - () => DesktopNotification.NotSupported !== this.desktopNotificationPermissions() - ); - - this.isDesktopNotificationDenied = ko.computed( - () => - DesktopNotification.NotSupported === this.desktopNotificationPermissions() || - DesktopNotification.Denied === this.desktopNotificationPermissions() - ); - - this.initNotificationPlayer(); - } - - initNotificationPlayer() { - if (Audio && Audio.supportedNotification) { - this.soundNotificationIsSupported(true); - } else { - this.enableSoundNotification(false); - this.soundNotificationIsSupported(false); - } + this.enableDesktopNotification.subscribe(value => { + DesktopNotifications = !!value; + if (value && HTML5Notification && 'granted' !== HTML5Notification.permission) { + HTML5Notification.requestPermission(() => + this.isDesktopNotificationDenied('denied' === HTML5Notification.permission) + ); + } + }); } + /** + * Used with SoundNotification setting + */ playSoundNotification(skipSetting) { - if (Audio && Audio.supportedNotification && (skipSetting ? true : this.enableSoundNotification())) { + if (skipSetting ? true : this.enableSoundNotification()) { Audio.playNotification(); } } - displayDesktopNotification(imageSrc, title, text, messageData) { - if (this.enableDesktopNotification()) { - const NotificationClass = this.notificationClass(), - notification = NotificationClass - ? new NotificationClass(title, { - body: text, - icon: imageSrc - }) - : null; - - if (notification) { - if (notification.show) { - notification.show(); - } - - if (messageData) { - notification.onclick = () => { - focus(); - - if (messageData.Folder && messageData.Uid) { - dispatchEvent(new CustomEvent('mailbox.message.show', {detail:messageData})); - } - }; - } - - setTimeout( - (function(localNotifications) { - return () => { - if (localNotifications.cancel) { - localNotifications.cancel(); - } else if (localNotifications.close) { - localNotifications.close(); - } - }; - })(notification), - 7000 - ); + /** + * Used with DesktopNotifications setting + */ + displayDesktopNotification(title, text, messageData, imageSrc) { + if (DesktopNotifications && 'granted' === HTML5NotificationStatus()) { + const options = { + body: text, + icon: imageSrc || Links.notificationMailIcon(), + data: messageData + }; + if (WorkerNotifications) { + // Service-Worker-Allowed HTTP header to allow the scope. + WorkerNotifications.register('/serviceworker.js') +// WorkerNotifications.register(Links.staticPrefix('js/serviceworker.js'), {scope:'/'}) + .then(() => + WorkerNotifications.ready.then(registration => + /* Show the notification */ + registration + .showNotification(title, options) + .then(() => + registration.getNotifications().then((/*notifications*/) => { + /* Send an empty message so the Worker knows who the client is */ + registration.active.postMessage(''); + }) + ) + ) + ) + .catch(e => console.error(e)); + } else { + const notification = new HTML5Notification(title, options); + notification.show && notification.show(); + notification.onclick = messageData ? () => dispatchMessage(messageData) : null; + setTimeout(() => notification.close(), 7000); } } } @@ -157,13 +99,6 @@ class NotificationUserStore { this.enableSoundNotification(!!rl.settings.get('SoundNotification')); this.enableDesktopNotification(!!rl.settings.get('DesktopNotifications')); } - - /** - * @returns {*|null} - */ - notificationClass() { - return window.Notification && Notification.requestPermission ? Notification : null; - } } export default new NotificationUserStore(); diff --git a/dev/serviceworker.js b/dev/serviceworker.js new file mode 100644 index 000000000..a1a93ff45 --- /dev/null +++ b/dev/serviceworker.js @@ -0,0 +1,15 @@ +'use strict'; + +self.addEventListener('message', event => self.client = event.source); + +const fn = event => { + self.client.postMessage( + JSON.stringify({ + data: event.notification.data, + action: event.type + }) + ); +}; + +self.onnotificationclose = fn; +self.onnotificationclick = fn; diff --git a/snappymail/v/0.0.0/app/templates/Views/User/SettingsGeneral.html b/snappymail/v/0.0.0/app/templates/Views/User/SettingsGeneral.html index cf6d01a90..0314ad323 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/SettingsGeneral.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/SettingsGeneral.html @@ -114,13 +114,13 @@ -
+
-
+
  - - + + +
-
+
-    +   diff --git a/tasks/js.js b/tasks/js.js index 401f4935d..218b20d57 100644 --- a/tasks/js.js +++ b/tasks/js.js @@ -30,6 +30,13 @@ const jsBoot = () => { .pipe(gulp.dest('snappymail/v/' + config.devVersion + '/static/js')); }; +// ServiceWorker +const jsServiceWorker = () => { + return gulp + .src('dev/serviceworker.js') + .pipe(gulp.dest('snappymail/v/' + config.devVersion + '/static/js')); +}; + // libs const jsLibs = () => { const src = config.paths.js.libs.src; @@ -113,7 +120,7 @@ const jsLint = () => .pipe(eslint.failAfterError()); const jsState1 = gulp.series(jsLint); -const jsState3 = gulp.parallel(jsBoot, jsLibs, jsApp, jsAdmin); +const jsState3 = gulp.parallel(jsBoot, jsServiceWorker, jsLibs, jsApp, jsAdmin); const jsState2 = gulp.series(jsClean, webpack, jsState3, jsMin); exports.jsLint = jsLint;