Testing van be done at https://snappymail.eu/demo/
This commit is contained in:
djmaze 2020-11-09 20:14:04 +01:00
parent 0ec37b7e90
commit 542d9c91e9
10 changed files with 215 additions and 270 deletions

View file

@ -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"
</IfModule>
Header set Service-Worker-Allowed "/"
<IfModule mod_headers.c>
RewriteCond %{HTTP:Accept-encoding} br
RewriteCond "%{REQUEST_FILENAME}\.br" -s
RewriteRule "^(.+)" "$1\.br" [L,T=text/javascript,QSA]

View file

@ -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.

View file

@ -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;

View file

@ -197,16 +197,6 @@ export const MessageSelectAction = {
Unflagged: 6
};
/**
* @enum {number}
*/
export const DesktopNotification = {
Allowed: 0,
NotAllowed: 1,
Denied: 2,
NotSupported: 9
};
/**
* @enum {number}
*/

View file

@ -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()]);
}

View file

@ -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 }

View file

@ -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();

15
dev/serviceworker.js Normal file
View file

@ -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;

View file

@ -114,13 +114,13 @@
</div>
</div>
</div>
<div class="form-horizontal hide-on-mobile" data-bind="visible: isDesktopNotificationSupported() || soundNotificationIsSupported()">
<div class="form-horizontal">
<div class="legend">
<span class="i18n i18n-animation" data-i18n="SETTINGS_GENERAL/LABEL_NOTIFICATIONS"></span>
</div>
<div class="control-group">
<div class="controls">
<div data-bind="visible: isDesktopNotificationSupported">
<div>
<div data-bind="component: {
name: 'Checkbox',
params: {
@ -131,11 +131,12 @@
}
}"></div>
&nbsp;
<span data-bind="visible: isDesktopNotificationDenied">
<span class="i18n" style="color: #999" data-i18n="SETTINGS_GENERAL/LABEL_CHROME_NOTIFICATION_DESC_DENIED"></span>
<span data-bind="visible: isDesktopNotificationDenied" class="i18n" style="color: #999" data-i18n="SETTINGS_GENERAL/LABEL_CHROME_NOTIFICATION_DESC_DENIED"></span>
<span data-bind="click: testSystemNotification" style="color:green;cursor:pointer">
<i class="icon-right-dir iconsize20"></i>
</span>
</div>
<div data-bind="visible: soundNotificationIsSupported">
<div>
<div data-bind="component: {
name: 'Checkbox',
params: {
@ -144,7 +145,7 @@
inline: true
}
}"></div>
&nbsp;&nbsp;
&nbsp;
<span data-bind="click: testSoundNotification" style="color:green;cursor:pointer">
<i class="icon-right-dir iconsize20"></i>
</span>

View file

@ -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;