mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-07-13 03:27:39 +03:00
Merge Objects prototype extensions
This commit is contained in:
parent
0647b5201f
commit
f2d194947d
26 changed files with 108 additions and 127 deletions
16
README.md
16
README.md
|
|
@ -86,21 +86,21 @@ Things might work in Edge 18, Firefox 50-62 and Chrome 54-68 due to one polyfill
|
||||||
|
|
||||||
|js/* |1.14.0 |native |
|
|js/* |1.14.0 |native |
|
||||||
|----------- |--------: |--------: |
|
|----------- |--------: |--------: |
|
||||||
|admin.js |2.130.942 | 939.722 |
|
|admin.js |2.130.942 | 938.738 |
|
||||||
|app.js |4.184.455 |2.559.620 |
|
|app.js |4.184.455 |2.556.224 |
|
||||||
|boot.js | 671.522 | 7.265 |
|
|boot.js | 671.522 | 7.265 |
|
||||||
|libs.js | 647.614 | 312.269 |
|
|libs.js | 647.614 | 312.343 |
|
||||||
|polyfills.js | 325.834 | 0 |
|
|polyfills.js | 325.834 | 0 |
|
||||||
|TOTAL |7.960.367 |3.818.876 |
|
|TOTAL |7.960.367 |3.814.570 |
|
||||||
|
|
||||||
|js/min/* |1.14.0 |native |gzip 1.14 |gzip |brotli |
|
|js/min/* |1.14.0 |native |gzip 1.14 |gzip |brotli |
|
||||||
|--------------- |--------: |--------: |--------: |--------: |--------: |
|
|--------------- |--------: |--------: |--------: |--------: |--------: |
|
||||||
|admin.min.js | 252.147 | 128.874 | 73.657 | 37.574 | 32.236 |
|
|admin.min.js | 252.147 | 128.761 | 73.657 | 37.546 | 32.178 |
|
||||||
|app.min.js | 511.202 | 349.777 |140.462 | 91.836 | 73.775 |
|
|app.min.js | 511.202 | 349.280 |140.462 | 91.746 | 73.635 |
|
||||||
|boot.min.js | 66.007 | 4.258 | 22.567 | 1.946 | 1.648 |
|
|boot.min.js | 66.007 | 4.258 | 22.567 | 1.946 | 1.648 |
|
||||||
|libs.min.js | 572.545 | 295.716 |176.720 | 91.532 | 80.863 |
|
|libs.min.js | 572.545 | 295.754 |176.720 | 91.521 | 80.871 |
|
||||||
|polyfills.min.js | 32.452 | 0 | 11.312 | 0 | 0 |
|
|polyfills.min.js | 32.452 | 0 | 11.312 | 0 | 0 |
|
||||||
|TOTAL |1.434.353 | 778.625 |424.718 |222.888 |188.522 |
|
|TOTAL |1.434.353 | 778.053 |424.718 |222.759 |188.332 |
|
||||||
|
|
||||||
655.728 bytes (201.830 gzip) is not much, but it feels faster.
|
655.728 bytes (201.830 gzip) is not much, but it feels faster.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import {
|
||||||
isPosNumeric,
|
isPosNumeric,
|
||||||
isNonEmptyArray,
|
|
||||||
pInt,
|
pInt,
|
||||||
pString,
|
pString,
|
||||||
delegateRunOnDestroy,
|
delegateRunOnDestroy,
|
||||||
|
|
@ -89,9 +88,9 @@ class AppUser extends AbstractApp {
|
||||||
|
|
||||||
// wakeUp
|
// wakeUp
|
||||||
const interval = 3600000; // 60m
|
const interval = 3600000; // 60m
|
||||||
var lastTime = (new Date()).getTime();
|
var lastTime = Date.now();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const currentTime = (new Date()).getTime();
|
const currentTime = Date.now();
|
||||||
if (currentTime > (lastTime + interval + 1000)) {
|
if (currentTime > (lastTime + interval + 1000)) {
|
||||||
if (rl.hash.check()) {
|
if (rl.hash.check()) {
|
||||||
this.reload();
|
this.reload();
|
||||||
|
|
@ -645,10 +644,10 @@ class AppUser extends AbstractApp {
|
||||||
*/
|
*/
|
||||||
folderInformationMultiply(boot = false) {
|
folderInformationMultiply(boot = false) {
|
||||||
const folders = FolderStore.getNextFolderNames();
|
const folders = FolderStore.getNextFolderNames();
|
||||||
if (isNonEmptyArray(folders)) {
|
if (Array.isNotEmpty(folders)) {
|
||||||
Remote.folderInformationMultiply((sResult, oData) => {
|
Remote.folderInformationMultiply((sResult, oData) => {
|
||||||
if (StorageResultType.Success === sResult) {
|
if (StorageResultType.Success === sResult) {
|
||||||
if (oData && oData.Result && oData.Result.List && isNonEmptyArray(oData.Result.List)) {
|
if (oData && oData.Result && oData.Result.List && Array.isNotEmpty(oData.Result.List)) {
|
||||||
const utc = Date.now() / 1000;
|
const utc = Date.now() / 1000;
|
||||||
oData.Result.List.forEach(item => {
|
oData.Result.List.forEach(item => {
|
||||||
const hash = getFolderHash(item.Folder),
|
const hash = getFolderHash(item.Folder),
|
||||||
|
|
@ -685,7 +684,7 @@ class AppUser extends AbstractApp {
|
||||||
} else if (unreadCountChange) {
|
} else if (unreadCountChange) {
|
||||||
if (folder.fullNameRaw === FolderStore.currentFolderFullNameRaw()) {
|
if (folder.fullNameRaw === FolderStore.currentFolderFullNameRaw()) {
|
||||||
const list = MessageStore.messageList();
|
const list = MessageStore.messageList();
|
||||||
if (isNonEmptyArray(list)) {
|
if (Array.isNotEmpty(list)) {
|
||||||
this.folderInformation(folder.fullNameRaw, list);
|
this.folderInformation(folder.fullNameRaw, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ export function storeMessageFlagsToCache(message) {
|
||||||
* @param {Array} flags
|
* @param {Array} flags
|
||||||
*/
|
*/
|
||||||
export function storeMessageFlagsToCacheByFolderAndUid(folder, uid, flags) {
|
export function storeMessageFlagsToCacheByFolderAndUid(folder, uid, flags) {
|
||||||
if (Array.isArray(flags) && flags.length) {
|
if (Array.isNotEmpty(flags)) {
|
||||||
setMessageFlagsToCache(folder, uid, flags);
|
setMessageFlagsToCache(folder, uid, flags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +279,7 @@ export function storeMessageFlagsToCacheBySetAction(folder, uid, setAction) {
|
||||||
let unread = 0;
|
let unread = 0;
|
||||||
const flags = getMessageFlagsFromCache(folder, uid);
|
const flags = getMessageFlagsFromCache(folder, uid);
|
||||||
|
|
||||||
if (Array.isArray(flags) && flags.length) {
|
if (Array.isNotEmpty(flags)) {
|
||||||
if (flags[0]) {
|
if (flags[0]) {
|
||||||
unread = 1;
|
unread = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,6 @@ export function pString(value) {
|
||||||
return null != value ? '' + value : '';
|
return null != value ? '' + value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {*} values
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isNonEmptyArray(values) {
|
|
||||||
return isArray(values) && values.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} queryString
|
* @param {string} queryString
|
||||||
* @returns {Object}
|
* @returns {Object}
|
||||||
|
|
|
||||||
2
dev/External/ko.js
vendored
2
dev/External/ko.js
vendored
|
|
@ -485,7 +485,7 @@ ko.extenders.limitedList = (target, limitedList) => {
|
||||||
const currentValue = ko.unwrap(target),
|
const currentValue = ko.unwrap(target),
|
||||||
list = ko.unwrap(limitedList);
|
list = ko.unwrap(limitedList);
|
||||||
|
|
||||||
if (Array.isArray(list) && list.length) {
|
if (Array.isNotEmpty(list)) {
|
||||||
if (list.includes(newValue)) {
|
if (list.includes(newValue)) {
|
||||||
target(newValue);
|
target(newValue);
|
||||||
} else if (list.includes(currentValue, list)) {
|
} else if (list.includes(currentValue, list)) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { isNonEmptyArray } from 'Common/Utils';
|
|
||||||
import { EmailModel } from 'Model/Email';
|
import { EmailModel } from 'Model/Email';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,7 +8,7 @@ import { EmailModel } from 'Model/Email';
|
||||||
*/
|
*/
|
||||||
export function emailArrayToString(emails, friendlyView = false, wrapWithLink = false) {
|
export function emailArrayToString(emails, friendlyView = false, wrapWithLink = false) {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (isNonEmptyArray(emails)) {
|
if (Array.isNotEmpty(emails)) {
|
||||||
emails.forEach(email => result.push(email.toLine(friendlyView, wrapWithLink)));
|
emails.forEach(email => result.push(email.toLine(friendlyView, wrapWithLink)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +21,7 @@ export function emailArrayToString(emails, friendlyView = false, wrapWithLink =
|
||||||
*/
|
*/
|
||||||
export function emailArrayToStringClear(emails) {
|
export function emailArrayToStringClear(emails) {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (isNonEmptyArray(emails)) {
|
if (Array.isNotEmpty(emails)) {
|
||||||
emails.forEach(email => {
|
emails.forEach(email => {
|
||||||
if (email && email.email && email.name) {
|
if (email && email.email && email.name) {
|
||||||
result.push(email.email);
|
result.push(email.email);
|
||||||
|
|
@ -39,7 +38,7 @@ export function emailArrayToStringClear(emails) {
|
||||||
*/
|
*/
|
||||||
export function emailArrayFromJson(json) {
|
export function emailArrayFromJson(json) {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (isNonEmptyArray(json)) {
|
if (Array.isNotEmpty(json)) {
|
||||||
json.forEach(email => {
|
json.forEach(email => {
|
||||||
email = EmailModel.newInstanceFromJson(email);
|
email = EmailModel.newInstanceFromJson(email);
|
||||||
if (email) {
|
if (email) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export class AbstractScreen {
|
||||||
fMatcher = null;
|
fMatcher = null;
|
||||||
const routes = this.routes();
|
const routes = this.routes();
|
||||||
|
|
||||||
if (Array.isArray(routes) && routes.length) {
|
if (Array.isNotEmpty(routes)) {
|
||||||
fMatcher = (this.onRoute || (()=>{})).bind(this);
|
fMatcher = (this.onRoute || (()=>{})).bind(this);
|
||||||
route = new Crossroads();
|
route = new Crossroads();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ let currentScreen = null,
|
||||||
|
|
||||||
const SCREENS = {},
|
const SCREENS = {},
|
||||||
qs = s => document.querySelector(s),
|
qs = s => document.querySelector(s),
|
||||||
isNonEmptyArray = values => Array.isArray(values) && values.length,
|
isNonEmptyArray = Array.isNotEmpty,
|
||||||
autofocus = dom => {
|
autofocus = dom => {
|
||||||
// if (!bMobileDevice) {
|
// if (!bMobileDevice) {
|
||||||
const af = dom.querySelector('[autofocus]');
|
const af = dom.querySelector('[autofocus]');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ko from 'ko';
|
||||||
|
|
||||||
import { FileType } from 'Common/Enums';
|
import { FileType } from 'Common/Enums';
|
||||||
import { bMobileDevice } from 'Common/Globals';
|
import { bMobileDevice } from 'Common/Globals';
|
||||||
import { pInt, isNonEmptyArray, getFileExtension, friendlySize } from 'Common/Utils';
|
import { pInt, getFileExtension, friendlySize } from 'Common/Utils';
|
||||||
import {
|
import {
|
||||||
attachmentDownload,
|
attachmentDownload,
|
||||||
attachmentPreview,
|
attachmentPreview,
|
||||||
|
|
@ -149,7 +149,7 @@ export const staticCombinedIconClass = (data) => {
|
||||||
let result = '',
|
let result = '',
|
||||||
types = [];
|
types = [];
|
||||||
|
|
||||||
if (isNonEmptyArray(data)) {
|
if (Array.isNotEmpty(data)) {
|
||||||
result = 'icon-attachment';
|
result = 'icon-attachment';
|
||||||
types = data.map(item => item ? staticFileType(getFileExtension(item[0]), item[1]) : '')
|
types = data.map(item => item ? staticFileType(getFileExtension(item[0]), item[1]) : '')
|
||||||
.filter((value, index, self) => !!value && self.indexOf(value) == index);
|
.filter((value, index, self) => !!value && self.indexOf(value) == index);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { ContactPropertyType } from 'Common/Enums';
|
import { ContactPropertyType } from 'Common/Enums';
|
||||||
import { isNonEmptyArray, pInt, pString } from 'Common/Utils';
|
import { pInt, pString } from 'Common/Utils';
|
||||||
import { emptyContactPic } from 'Common/Links';
|
import { emptyContactPic } from 'Common/Links';
|
||||||
|
|
||||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
|
@ -28,7 +28,7 @@ class ContactModel extends AbstractModel {
|
||||||
let name = '',
|
let name = '',
|
||||||
email = '';
|
email = '';
|
||||||
|
|
||||||
if (isNonEmptyArray(this.properties)) {
|
if (Array.isNotEmpty(this.properties)) {
|
||||||
this.properties.forEach(property => {
|
this.properties.forEach(property => {
|
||||||
if (property) {
|
if (property) {
|
||||||
if (ContactPropertyType.FirstName === property[0]) {
|
if (ContactPropertyType.FirstName === property[0]) {
|
||||||
|
|
@ -56,7 +56,7 @@ class ContactModel extends AbstractModel {
|
||||||
this.display = pString(json.Display);
|
this.display = pString(json.Display);
|
||||||
this.readOnly = !!json.ReadOnly;
|
this.readOnly = !!json.ReadOnly;
|
||||||
|
|
||||||
if (isNonEmptyArray(json.Properties)) {
|
if (Array.isNotEmpty(json.Properties)) {
|
||||||
json.Properties.forEach(property => {
|
json.Properties.forEach(property => {
|
||||||
if (property && property.Type && null != property.Value && null != property.TypeStr) {
|
if (property && property.Type && null != property.Value && null != property.TypeStr) {
|
||||||
this.properties.push([pInt(property.Type), pString(property.Value), pString(property.TypeStr)]);
|
this.properties.push([pInt(property.Type), pString(property.Value), pString(property.TypeStr)]);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { FilterRulesType, FiltersAction } from 'Common/Enums';
|
import { FilterRulesType, FiltersAction } from 'Common/Enums';
|
||||||
import { pString, isNonEmptyArray, fakeMd5, delegateRunOnDestroy } from 'Common/Utils';
|
import { pString, fakeMd5, delegateRunOnDestroy } from 'Common/Utils';
|
||||||
import { i18n } from 'Common/Translator';
|
import { i18n } from 'Common/Translator';
|
||||||
import { getFolderFromCacheList } from 'Common/Cache';
|
import { getFolderFromCacheList } from 'Common/Cache';
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ class FilterModel extends AbstractModel {
|
||||||
|
|
||||||
this.conditions([]);
|
this.conditions([]);
|
||||||
|
|
||||||
if (isNonEmptyArray(json.Conditions)) {
|
if (Array.isNotEmpty(json.Conditions)) {
|
||||||
this.conditions(
|
this.conditions(
|
||||||
json.Conditions.map(aData => {
|
json.Conditions.map(aData => {
|
||||||
const filterCondition = new FilterConditionModel();
|
const filterCondition = new FilterConditionModel();
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import {
|
||||||
pInt,
|
pInt,
|
||||||
deModule,
|
deModule,
|
||||||
encodeHtml,
|
encodeHtml,
|
||||||
friendlySize,
|
friendlySize
|
||||||
isNonEmptyArray
|
|
||||||
} from 'Common/Utils';
|
} from 'Common/Utils';
|
||||||
|
|
||||||
import { messageViewLink, messageDownloadLink } from 'Common/Links';
|
import { messageViewLink, messageDownloadLink } from 'Common/Links';
|
||||||
|
|
@ -257,7 +256,7 @@ class MessageModel extends AbstractModel {
|
||||||
this.bcc = emailArrayFromJson(json.Bcc);
|
this.bcc = emailArrayFromJson(json.Bcc);
|
||||||
this.replyTo = emailArrayFromJson(json.ReplyTo);
|
this.replyTo = emailArrayFromJson(json.ReplyTo);
|
||||||
this.deliveredTo = emailArrayFromJson(json.DeliveredTo);
|
this.deliveredTo = emailArrayFromJson(json.DeliveredTo);
|
||||||
this.unsubsribeLinks = isNonEmptyArray(json.UnsubsribeLinks) ? json.UnsubsribeLinks : [];
|
this.unsubsribeLinks = Array.isNotEmpty(json.UnsubsribeLinks) ? json.UnsubsribeLinks : [];
|
||||||
|
|
||||||
this.subject(json.Subject);
|
this.subject(json.Subject);
|
||||||
if (isArray(json.SubjectParts)) {
|
if (isArray(json.SubjectParts)) {
|
||||||
|
|
@ -341,7 +340,7 @@ class MessageModel extends AbstractModel {
|
||||||
attachment = null;
|
attachment = null;
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
if (json && 'Collection/AttachmentCollection' === json['@Object'] && isNonEmptyArray(json['@Collection'])) {
|
if (json && 'Collection/AttachmentCollection' === json['@Object'] && Array.isNotEmpty(json['@Collection'])) {
|
||||||
for (index = 0, len = json['@Collection'].length; index < len; index++) {
|
for (index = 0, len = json['@Collection'].length; index < len; index++) {
|
||||||
attachment = AttachmentModel.newInstanceFromJson(json['@Collection'][index]);
|
attachment = AttachmentModel.newInstanceFromJson(json['@Collection'][index]);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -408,7 +407,7 @@ class MessageModel extends AbstractModel {
|
||||||
*/
|
*/
|
||||||
fromDkimData() {
|
fromDkimData() {
|
||||||
let result = ['none', ''];
|
let result = ['none', ''];
|
||||||
if (isNonEmptyArray(this.from) && 1 === this.from.length && this.from[0] && this.from[0].dkimStatus) {
|
if (Array.isNotEmpty(this.from) && 1 === this.from.length && this.from[0] && this.from[0].dkimStatus) {
|
||||||
result = [this.from[0].dkimStatus, this.from[0].dkimValue || ''];
|
result = [this.from[0].dkimStatus, this.from[0].dkimValue || ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,7 +491,7 @@ class MessageModel extends AbstractModel {
|
||||||
let result = null;
|
let result = null;
|
||||||
const attachments = this.attachments();
|
const attachments = this.attachments();
|
||||||
|
|
||||||
if (isNonEmptyArray(attachments)) {
|
if (Array.isNotEmpty(attachments)) {
|
||||||
cid = cid.replace(/^<+/, '').replace(/>+$/, '');
|
cid = cid.replace(/^<+/, '').replace(/>+$/, '');
|
||||||
result = attachments.find(item => cid === item.cidWithOutTags);
|
result = attachments.find(item => cid === item.cidWithOutTags);
|
||||||
}
|
}
|
||||||
|
|
@ -508,7 +507,7 @@ class MessageModel extends AbstractModel {
|
||||||
let result = null;
|
let result = null;
|
||||||
const attachments = this.attachments();
|
const attachments = this.attachments();
|
||||||
|
|
||||||
if (isNonEmptyArray(attachments)) {
|
if (Array.isNotEmpty(attachments)) {
|
||||||
result = attachments.find(item => contentLocation === item.contentLocation);
|
result = attachments.find(item => contentLocation === item.contentLocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { isNonEmptyArray } from 'Common/Utils';
|
|
||||||
|
|
||||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
|
||||||
import PgpStore from 'Stores/User/Pgp';
|
import PgpStore from 'Stores/User/Pgp';
|
||||||
|
|
@ -23,7 +21,7 @@ class OpenPgpKeyModel extends AbstractModel {
|
||||||
|
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.id = ID;
|
this.id = ID;
|
||||||
this.ids = isNonEmptyArray(IDs) ? IDs : [ID];
|
this.ids = Array.isNotEmpty(IDs) ? IDs : [ID];
|
||||||
this.guid = guID;
|
this.guid = guID;
|
||||||
this.user = '';
|
this.user = '';
|
||||||
this.users = userIDs;
|
this.users = userIDs;
|
||||||
|
|
|
||||||
|
|
@ -445,7 +445,7 @@ class RemoteUserAjax extends AbstractAjaxRemote {
|
||||||
let request = true;
|
let request = true;
|
||||||
const uids = [];
|
const uids = [];
|
||||||
|
|
||||||
if (Array.isArray(list) && list.length) {
|
if (Array.isNotEmpty(list)) {
|
||||||
request = false;
|
request = false;
|
||||||
list.forEach(messageListItem => {
|
list.forEach(messageListItem => {
|
||||||
if (!getMessageFlagsFromCache(messageListItem.folderFullNameRaw, messageListItem.uid)) {
|
if (!getMessageFlagsFromCache(messageListItem.folderFullNameRaw, messageListItem.uid)) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import ko from 'ko';
|
||||||
import { Focused, KeyState } from 'Common/Enums';
|
import { Focused, KeyState } from 'Common/Enums';
|
||||||
|
|
||||||
import { keyScope, leftPanelDisabled } from 'Common/Globals';
|
import { keyScope, leftPanelDisabled } from 'Common/Globals';
|
||||||
import { isNonEmptyArray } from 'Common/Utils';
|
|
||||||
|
|
||||||
import { AbstractAppStore } from 'Stores/AbstractApp';
|
import { AbstractAppStore } from 'Stores/AbstractApp';
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ class AppUserStore extends AbstractAppStore {
|
||||||
this.contactsIsAllowed(!!Settings.get('ContactsIsAllowed'));
|
this.contactsIsAllowed(!!Settings.get('ContactsIsAllowed'));
|
||||||
|
|
||||||
const attachmentsActions = Settings.app('attachmentsActions');
|
const attachmentsActions = Settings.app('attachmentsActions');
|
||||||
this.attachmentsActions(isNonEmptyArray(attachmentsActions) ? attachmentsActions : []);
|
this.attachmentsActions(Array.isNotEmpty(attachmentsActions) ? attachmentsActions : []);
|
||||||
|
|
||||||
this.devEmail = Settings.get('DevEmail');
|
this.devEmail = Settings.get('DevEmail');
|
||||||
this.devPassword = Settings.get('DevPassword');
|
this.devPassword = Settings.get('DevPassword');
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class FolderUserStore {
|
||||||
trashFolder = this.trashFolder(),
|
trashFolder = this.trashFolder(),
|
||||||
archiveFolder = this.archiveFolder();
|
archiveFolder = this.archiveFolder();
|
||||||
|
|
||||||
if (Array.isArray(folders) && folders.length) {
|
if (Array.isNotEmpty(folders)) {
|
||||||
if (sentFolder && UNUSED_OPTION_VALUE !== sentFolder) {
|
if (sentFolder && UNUSED_OPTION_VALUE !== sentFolder) {
|
||||||
list.push(sentFolder);
|
list.push(sentFolder);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ class MessageUserStore {
|
||||||
|
|
||||||
initUidNextAndNewMessages(folder, uidNext, newMessages) {
|
initUidNextAndNewMessages(folder, uidNext, newMessages) {
|
||||||
if (getFolderInboxName() === folder && uidNext) {
|
if (getFolderInboxName() === folder && uidNext) {
|
||||||
if (Array.isArray(newMessages) && newMessages.length) {
|
if (Array.isNotEmpty(newMessages)) {
|
||||||
newMessages.forEach(item => {
|
newMessages.forEach(item => {
|
||||||
addNewMessageCache(folder, item.Uid);
|
addNewMessageCache(folder, item.Uid);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { i18n } from 'Common/Translator';
|
import { i18n } from 'Common/Translator';
|
||||||
import { isNonEmptyArray, pString } from 'Common/Utils';
|
import { pString } from 'Common/Utils';
|
||||||
|
|
||||||
import AccountStore from 'Stores/User/Account';
|
import AccountStore from 'Stores/User/Account';
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ function domControlEncryptedClickHelper(store, dom, armoredMessage, recipients)
|
||||||
decryptedMessage.getText()
|
decryptedMessage.getText()
|
||||||
);
|
);
|
||||||
} else if (validPrivateKey) {
|
} else if (validPrivateKey) {
|
||||||
const keyIds = isNonEmptyArray(signingKeyIds) ? signingKeyIds : null,
|
const keyIds = Array.isNotEmpty(signingKeyIds) ? signingKeyIds : null,
|
||||||
additional = keyIds
|
additional = keyIds
|
||||||
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(value => !!value).join(', ')
|
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(value => !!value).join(', ')
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -107,7 +107,7 @@ function domControlSignedClickHelper(store, dom, armoredMessage) {
|
||||||
message.getText()
|
message.getText()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const keyIds = isNonEmptyArray(signingKeyIds) ? signingKeyIds : null,
|
const keyIds = Array.isNotEmpty(signingKeyIds) ? signingKeyIds : null,
|
||||||
additional = keyIds
|
additional = keyIds
|
||||||
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(value => !!value).join(', ')
|
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(value => !!value).join(', ')
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -183,7 +183,7 @@ class PgpUserStore {
|
||||||
}).flat().filter(value => !!value)
|
}).flat().filter(value => !!value)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (!result.length && isNonEmptyArray(recipients)) {
|
if (!result.length && Array.isNotEmpty(recipients)) {
|
||||||
result = recipients.map(sEmail => {
|
result = recipients.map(sEmail => {
|
||||||
const keys = sEmail ? this.findAllPrivateKeysByEmailNotNative(sEmail) : null;
|
const keys = sEmail ? this.findAllPrivateKeysByEmailNotNative(sEmail) : null;
|
||||||
return keys
|
return keys
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from 'Common/Enums';
|
} from 'Common/Enums';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isNonEmptyArray,
|
|
||||||
replySubjectAdd,
|
replySubjectAdd,
|
||||||
encodeHtml,
|
encodeHtml,
|
||||||
inFocus,
|
inFocus,
|
||||||
|
|
@ -263,7 +262,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.attachmentsInProcess.subscribe(value => {
|
this.attachmentsInProcess.subscribe(value => {
|
||||||
if (this.attachmentsInProcessError() && isNonEmptyArray(value)) {
|
if (this.attachmentsInProcessError() && Array.isNotEmpty(value)) {
|
||||||
this.attachmentsInProcessError(false);
|
this.attachmentsInProcessError(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -772,7 +771,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
* @param {Array} emails
|
* @param {Array} emails
|
||||||
*/
|
*/
|
||||||
addEmailsTo(fKoValue, emails) {
|
addEmailsTo(fKoValue, emails) {
|
||||||
if (isNonEmptyArray(emails)) {
|
if (Array.isNotEmpty(emails)) {
|
||||||
const value = fKoValue().trim(),
|
const value = fKoValue().trim(),
|
||||||
values = emails.map(item => item ? item.toLine(false) : null)
|
values = emails.map(item => item ? item.toLine(false) : null)
|
||||||
.filter((value, index, self) => !!value && self.indexOf(value) == index);
|
.filter((value, index, self) => !!value && self.indexOf(value) == index);
|
||||||
|
|
@ -842,15 +841,15 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
excludeEmail[identity.email()] = true;
|
excludeEmail[identity.email()] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNonEmptyArray(aToEmails)) {
|
if (Array.isNotEmpty(aToEmails)) {
|
||||||
this.to(this.emailArrayToStringLineHelper(aToEmails));
|
this.to(this.emailArrayToStringLineHelper(aToEmails));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNonEmptyArray(aCcEmails)) {
|
if (Array.isNotEmpty(aCcEmails)) {
|
||||||
this.cc(this.emailArrayToStringLineHelper(aCcEmails));
|
this.cc(this.emailArrayToStringLineHelper(aCcEmails));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNonEmptyArray(aBccEmails)) {
|
if (Array.isNotEmpty(aBccEmails)) {
|
||||||
this.bcc(this.emailArrayToStringLineHelper(aBccEmails));
|
this.bcc(this.emailArrayToStringLineHelper(aBccEmails));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -915,7 +914,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
this.subject(sSubject);
|
this.subject(sSubject);
|
||||||
this.prepearMessageAttachments(message, lineComposeType);
|
this.prepearMessageAttachments(message, lineComposeType);
|
||||||
|
|
||||||
this.aDraftInfo = isNonEmptyArray(aDraftInfo) && 3 === aDraftInfo.length ? aDraftInfo : null;
|
this.aDraftInfo = Array.isNotEmpty(aDraftInfo) && 3 === aDraftInfo.length ? aDraftInfo : null;
|
||||||
this.sInReplyTo = message.sInReplyTo;
|
this.sInReplyTo = message.sInReplyTo;
|
||||||
this.sReferences = message.sReferences;
|
this.sReferences = message.sReferences;
|
||||||
break;
|
break;
|
||||||
|
|
@ -929,7 +928,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
this.subject(sSubject);
|
this.subject(sSubject);
|
||||||
this.prepearMessageAttachments(message, lineComposeType);
|
this.prepearMessageAttachments(message, lineComposeType);
|
||||||
|
|
||||||
this.aDraftInfo = isNonEmptyArray(aDraftInfo) && 3 === aDraftInfo.length ? aDraftInfo : null;
|
this.aDraftInfo = Array.isNotEmpty(aDraftInfo) && 3 === aDraftInfo.length ? aDraftInfo : null;
|
||||||
this.sInReplyTo = message.sInReplyTo;
|
this.sInReplyTo = message.sInReplyTo;
|
||||||
this.sReferences = message.sReferences;
|
this.sReferences = message.sReferences;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1021,7 +1020,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
|
|
||||||
this.setFocusInPopup();
|
this.setFocusInPopup();
|
||||||
});
|
});
|
||||||
} else if (isNonEmptyArray(oMessageOrArray)) {
|
} else if (Array.isNotEmpty(oMessageOrArray)) {
|
||||||
oMessageOrArray.forEach(item => {
|
oMessageOrArray.forEach(item => {
|
||||||
this.addMessageAsAttachment(item);
|
this.addMessageAsAttachment(item);
|
||||||
});
|
});
|
||||||
|
|
@ -1047,7 +1046,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloads = this.getAttachmentsDownloadsForUpload();
|
const downloads = this.getAttachmentsDownloadsForUpload();
|
||||||
if (isNonEmptyArray(downloads)) {
|
if (Array.isNotEmpty(downloads)) {
|
||||||
Remote.messageUploadAttachments(()=>this.onMessageUploadAttachments(), downloads);
|
Remote.messageUploadAttachments(()=>this.onMessageUploadAttachments(), downloads);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1375,7 +1374,7 @@ class ComposePopupView extends AbstractViewNext {
|
||||||
this.addMessageAsAttachment(message);
|
this.addMessageAsAttachment(message);
|
||||||
} else {
|
} else {
|
||||||
const attachments = message.attachments();
|
const attachments = message.attachments();
|
||||||
(isNonEmptyArray(attachments) ? attachments : []).forEach(item => {
|
(Array.isNotEmpty(attachments) ? attachments : []).forEach(item => {
|
||||||
let add = false;
|
let add = false;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ComposeType.Reply:
|
case ComposeType.Reply:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
import {
|
import {
|
||||||
delegateRunOnDestroy,
|
delegateRunOnDestroy,
|
||||||
computedPagenatorHelper,
|
computedPagenatorHelper,
|
||||||
isNonEmptyArray,
|
|
||||||
fakeMd5,
|
fakeMd5,
|
||||||
pInt
|
pInt
|
||||||
} from 'Common/Utils';
|
} from 'Common/Utils';
|
||||||
|
|
@ -229,7 +228,7 @@ class ContactsPopupView extends AbstractViewNext {
|
||||||
bccEmails = null;
|
bccEmails = null;
|
||||||
|
|
||||||
const aC = this.contactsCheckedOrSelected();
|
const aC = this.contactsCheckedOrSelected();
|
||||||
if (isNonEmptyArray(aC)) {
|
if (Array.isNotEmpty(aC)) {
|
||||||
aE = aC.map(oItem => {
|
aE = aC.map(oItem => {
|
||||||
if (oItem) {
|
if (oItem) {
|
||||||
const data = oItem.getNameAndEmailHelper(),
|
const data = oItem.getNameAndEmailHelper(),
|
||||||
|
|
@ -246,7 +245,7 @@ class ContactsPopupView extends AbstractViewNext {
|
||||||
aE = aE.filter(value => !!value);
|
aE = aE.filter(value => !!value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNonEmptyArray(aE)) {
|
if (Array.isNotEmpty(aE)) {
|
||||||
this.bBackToCompose = false;
|
this.bBackToCompose = false;
|
||||||
|
|
||||||
hideScreenPopup(ContactsPopupView);
|
hideScreenPopup(ContactsPopupView);
|
||||||
|
|
@ -511,7 +510,7 @@ class ContactsPopupView extends AbstractViewNext {
|
||||||
|
|
||||||
if (contact) {
|
if (contact) {
|
||||||
id = contact.idContact;
|
id = contact.idContact;
|
||||||
if (isNonEmptyArray(contact.properties)) {
|
if (Array.isNotEmpty(contact.properties)) {
|
||||||
contact.properties.forEach(property => {
|
contact.properties.forEach(property => {
|
||||||
if (property && property[0]) {
|
if (property && property[0]) {
|
||||||
if (ContactPropertyType.LastName === property[0]) {
|
if (ContactPropertyType.LastName === property[0]) {
|
||||||
|
|
@ -579,7 +578,7 @@ class ContactsPopupView extends AbstractViewNext {
|
||||||
list = [];
|
list = [];
|
||||||
|
|
||||||
if (StorageResultType.Success === result && data && data.Result && data.Result.List) {
|
if (StorageResultType.Success === result && data && data.Result && data.Result.List) {
|
||||||
if (isNonEmptyArray(data.Result.List)) {
|
if (Array.isNotEmpty(data.Result.List)) {
|
||||||
list = data.Result.List.map(item => {
|
list = data.Result.List.map(item => {
|
||||||
const contact = new ContactModel();
|
const contact = new ContactModel();
|
||||||
return contact.parse(item) ? contact : null;
|
return contact.parse(item) ? contact : null;
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ class PluginPopupView extends AbstractViewNext {
|
||||||
this.readme(oPlugin.Readme);
|
this.readme(oPlugin.Readme);
|
||||||
|
|
||||||
const config = oPlugin.Config;
|
const config = oPlugin.Config;
|
||||||
if (Array.isArray(config) && config.length) {
|
if (Array.isNotEmpty(config)) {
|
||||||
this.configures(
|
this.configures(
|
||||||
config.map(item => ({
|
config.map(item => ({
|
||||||
'value': ko.observable(item[0]),
|
'value': ko.observable(item[0]),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
import { $htmlCL, leftPanelDisabled, keyScopeReal, useKeyboardShortcuts, moveAction } from 'Common/Globals';
|
import { $htmlCL, leftPanelDisabled, keyScopeReal, useKeyboardShortcuts, moveAction } from 'Common/Globals';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isNonEmptyArray,
|
|
||||||
inFocus,
|
inFocus,
|
||||||
removeSelection,
|
removeSelection,
|
||||||
removeInFocus,
|
removeInFocus,
|
||||||
|
|
@ -213,7 +212,7 @@ class MessageViewMailBoxUserView extends AbstractViewNext {
|
||||||
|
|
||||||
this.viewFromDkimStatusTitle = ko.computed(() => {
|
this.viewFromDkimStatusTitle = ko.computed(() => {
|
||||||
const status = this.viewFromDkimData();
|
const status = this.viewFromDkimData();
|
||||||
if (isNonEmptyArray(status)) {
|
if (Array.isNotEmpty(status)) {
|
||||||
if (status[0] && status[1]) {
|
if (status[0] && status[1]) {
|
||||||
return status[1];
|
return status[1];
|
||||||
} else if (status[0]) {
|
} else if (status[0]) {
|
||||||
|
|
|
||||||
13
dev/prototype-element.js
vendored
13
dev/prototype-element.js
vendored
|
|
@ -1,13 +0,0 @@
|
||||||
|
|
||||||
(()=>{
|
|
||||||
Element.prototype.closestWithin = function(selector, parent) {
|
|
||||||
const el = this.closest(selector);
|
|
||||||
return (el && el !== parent && parent.contains(el)) ? el : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
Element.fromHTML = string => {
|
|
||||||
const template = document.createElement('template');
|
|
||||||
template.innerHTML = string.trim();
|
|
||||||
return template.content.firstChild;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
34
dev/prototype-function.js
vendored
34
dev/prototype-function.js
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
/**
|
|
||||||
* Every time the function is executed,
|
|
||||||
* it will delay the execution with the given amount of milliseconds.
|
|
||||||
*/
|
|
||||||
if (!Function.prototype.debounce) {
|
|
||||||
Function.prototype.debounce = function(ms) {
|
|
||||||
let func = this, timer;
|
|
||||||
return function(...args) {
|
|
||||||
timer && clearTimeout(timer);
|
|
||||||
timer = setTimeout(()=>{
|
|
||||||
func.apply(this, args)
|
|
||||||
timer = 0;
|
|
||||||
}, ms);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No matter how many times the event is executed,
|
|
||||||
* the function will be executed only once, after the given amount of milliseconds.
|
|
||||||
*/
|
|
||||||
if (!Function.prototype.throttle) {
|
|
||||||
Function.prototype.throttle = function(ms) {
|
|
||||||
let func = this, timer;
|
|
||||||
return function(...args) {
|
|
||||||
if (!timer) {
|
|
||||||
timer = setTimeout(()=>{
|
|
||||||
func.apply(this, args)
|
|
||||||
timer = 0;
|
|
||||||
}, ms);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
49
dev/prototype-date.js → dev/prototype.js
vendored
49
dev/prototype-date.js → dev/prototype.js
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
(w=>{
|
(w=>{
|
||||||
|
Array.isNotEmpty = array => Array.isArray(array) && array.length;
|
||||||
|
|
||||||
// Import momentjs locales function
|
// Import momentjs locales function
|
||||||
w.moment = {
|
w.moment = {
|
||||||
|
|
@ -167,7 +168,7 @@
|
||||||
// Simulate momentjs fromNow function
|
// Simulate momentjs fromNow function
|
||||||
Date.prototype.fromNow = function() {
|
Date.prototype.fromNow = function() {
|
||||||
let format,
|
let format,
|
||||||
seconds = ((new Date()).getTime() - this.getTime()) / 1000,
|
seconds = (Date.now() - this.getTime()) / 1000,
|
||||||
str = locale.relativeTime[0 < seconds ? 'past' : 'future'];
|
str = locale.relativeTime[0 < seconds ? 'past' : 'future'];
|
||||||
seconds = Math.abs(seconds);
|
seconds = Math.abs(seconds);
|
||||||
if (60 > seconds) {
|
if (60 > seconds) {
|
||||||
|
|
@ -195,4 +196,50 @@
|
||||||
return str.replace('%s', locale.relativeTime[format].replace('%d', seconds));
|
return str.replace('%s', locale.relativeTime[format].replace('%d', seconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Element.prototype.closestWithin = function(selector, parent) {
|
||||||
|
const el = this.closest(selector);
|
||||||
|
return (el && el !== parent && parent.contains(el)) ? el : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Element.fromHTML = string => {
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = string.trim();
|
||||||
|
return template.content.firstChild;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every time the function is executed,
|
||||||
|
* it will delay the execution with the given amount of milliseconds.
|
||||||
|
*/
|
||||||
|
if (!Function.prototype.debounce) {
|
||||||
|
Function.prototype.debounce = function(ms) {
|
||||||
|
let func = this, timer;
|
||||||
|
return function(...args) {
|
||||||
|
timer && clearTimeout(timer);
|
||||||
|
timer = setTimeout(()=>{
|
||||||
|
func.apply(this, args)
|
||||||
|
timer = 0;
|
||||||
|
}, ms);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No matter how many times the event is executed,
|
||||||
|
* the function will be executed only once, after the given amount of milliseconds.
|
||||||
|
*/
|
||||||
|
if (!Function.prototype.throttle) {
|
||||||
|
Function.prototype.throttle = function(ms) {
|
||||||
|
let func = this, timer;
|
||||||
|
return function(...args) {
|
||||||
|
if (!timer) {
|
||||||
|
timer = setTimeout(()=>{
|
||||||
|
func.apply(this, args)
|
||||||
|
timer = 0;
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
})(this);
|
})(this);
|
||||||
|
|
@ -70,6 +70,7 @@ config.paths.js = {
|
||||||
name: 'libs.js',
|
name: 'libs.js',
|
||||||
src: [
|
src: [
|
||||||
'dev/polyfill.js',
|
'dev/polyfill.js',
|
||||||
|
'dev/prototype.js',
|
||||||
'node_modules/jquery/dist/jquery.slim.min.js',
|
'node_modules/jquery/dist/jquery.slim.min.js',
|
||||||
'vendors/jquery-ui/js/jquery-ui-1.12.1.custom.min.js', // custom
|
'vendors/jquery-ui/js/jquery-ui-1.12.1.custom.min.js', // custom
|
||||||
'vendors/inputosaurus/inputosaurus.js', // custom (modified)
|
'vendors/inputosaurus/inputosaurus.js', // custom (modified)
|
||||||
|
|
@ -80,9 +81,6 @@ config.paths.js = {
|
||||||
'vendors/keymaster/keymaster.js', // custom (modified)
|
'vendors/keymaster/keymaster.js', // custom (modified)
|
||||||
'vendors/qr.js/qr.min.js', // fixed (license)
|
'vendors/qr.js/qr.min.js', // fixed (license)
|
||||||
'vendors/bootstrap/js/bootstrap.native.min.js', // fixed
|
'vendors/bootstrap/js/bootstrap.native.min.js', // fixed
|
||||||
'dev/prototype-date.js',
|
|
||||||
'dev/prototype-element.js',
|
|
||||||
'dev/prototype-function.js',
|
|
||||||
'node_modules/knockout/build/output/knockout-latest.js',
|
'node_modules/knockout/build/output/knockout-latest.js',
|
||||||
'node_modules/knockout-sortable/build/knockout-sortable.min.js ',
|
'node_modules/knockout-sortable/build/knockout-sortable.min.js ',
|
||||||
'node_modules/simplestatemanager/dist/ssm.min.js',
|
'node_modules/simplestatemanager/dist/ssm.min.js',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue