mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-09-08 08:57:03 +03:00
Merge branch 'the-djmaze:master' into master
This commit is contained in:
commit
15aa1ce24f
46 changed files with 1500 additions and 627 deletions
|
|
@ -289,5 +289,5 @@ dev_email = ""
|
||||||
dev_password = ""
|
dev_password = ""
|
||||||
|
|
||||||
[version]
|
[version]
|
||||||
current = "2.22.3"
|
current = "2.22.4"
|
||||||
saved = "Fri, 30 Sep 2022 12:34:56 +0000"
|
saved = "Fri, 30 Sep 2022 12:34:56 +0000"
|
||||||
|
|
|
||||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,3 +1,23 @@
|
||||||
|
## 2.22.4 – 2022-11-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Contacts dialog layout using flex
|
||||||
|
- Session token is related to the user agent string
|
||||||
|
[#713](https://github.com/the-djmaze/snappymail/pull/713)
|
||||||
|
- Better browser cache handling for avatars plugin
|
||||||
|
[#714](https://github.com/the-djmaze/snappymail/pull/714)
|
||||||
|
- Force HTML editor when set as default when replying to message
|
||||||
|
[#355](https://github.com/the-djmaze/snappymail/pull/355)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Contact Error - object Object #716
|
||||||
|
[#716](https://github.com/the-djmaze/snappymail/pull/716)
|
||||||
|
- Unable to move messages to different folder by drag and drop
|
||||||
|
[#710](https://github.com/the-djmaze/snappymail/pull/710)
|
||||||
|
- v2.22.3 unknown error #709
|
||||||
|
[#709https://github.com/the-djmaze/snappymail/pull/709)
|
||||||
|
|
||||||
|
|
||||||
## 2.22.3 – 2022-11-25
|
## 2.22.3 – 2022-11-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -9,7 +29,7 @@
|
||||||
- Update to OpenPGP.js v5.5.0
|
- Update to OpenPGP.js v5.5.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- drag & drop folder expansion #707
|
- drag & drop folder expansion
|
||||||
[#707](https://github.com/the-djmaze/snappymail/pull/707)
|
[#707](https://github.com/the-djmaze/snappymail/pull/707)
|
||||||
- Save selected messages as .eml in Nextcloud failed
|
- Save selected messages as .eml in Nextcloud failed
|
||||||
[#704](https://github.com/the-djmaze/snappymail/pull/704)
|
[#704](https://github.com/the-djmaze/snappymail/pull/704)
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -142,25 +142,25 @@ RainLoop 1.17 vs SnappyMail
|
||||||
|js/* |RainLoop |Snappy |
|
|js/* |RainLoop |Snappy |
|
||||||
|--------------- |--------: |--------: |
|
|--------------- |--------: |--------: |
|
||||||
|admin.js |2.170.153 | 82.419 |
|
|admin.js |2.170.153 | 82.419 |
|
||||||
|app.js |4.207.787 | 407.335 |
|
|app.js |4.207.787 | 407.334 |
|
||||||
|boot.js | 868.735 | 1.989 |
|
|boot.js | 868.735 | 2.351 |
|
||||||
|libs.js | 658.812 | 193.075 |
|
|libs.js | 658.812 | 193.075 |
|
||||||
|sieve.js | 0 | 86.121 |
|
|sieve.js | 0 | 86.121 |
|
||||||
|polyfills.js | 334.608 | 0 |
|
|polyfills.js | 334.608 | 0 |
|
||||||
|serviceworker.js | 0 | 285 |
|
|serviceworker.js | 0 | 285 |
|
||||||
|TOTAL |8.240.095 | 771.224 |
|
|TOTAL |8.240.095 | 771.585 |
|
||||||
|
|
||||||
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||||
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
||||||
|admin.min.js | 256.831 | 40.933 | 73.606 | 13.575 | 60.877 | 12.185 |
|
|admin.min.js | 256.831 | 40.933 | 73.606 | 13.575 | 60.877 | 12.185 |
|
||||||
|app.min.js | 515.367 | 188.819 |139.456 | 62.830 |110.485 | 53.925 |
|
|app.min.js | 515.367 | 188.876 |139.456 | 62.841 |110.485 | 53.957 |
|
||||||
|boot.min.js | 84.659 | 1.216 | 26.998 | 761 | 23.643 | 614 |
|
|boot.min.js | 84.659 | 1.474 | 26.998 | 907 | 23.643 | 733 |
|
||||||
|libs.min.js | 584.772 | 92.084 |180.901 | 34.119 |155.182 | 30.621 |
|
|libs.min.js | 584.772 | 92.084 |180.901 | 34.119 |155.182 | 30.621 |
|
||||||
|sieve.min.js | 0 | 41.926 | 0 | 10.484 | 0 | 9.451 |
|
|sieve.min.js | 0 | 41.926 | 0 | 10.484 | 0 | 9.451 |
|
||||||
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
||||||
|TOTAL user |1.217.635 | 282.119 |358.761 | 97.710 |299.485 | 85.160 |
|
|TOTAL user |1.217.635 | 282.434 |358.761 | 97.867 |299.485 | 85.311 |
|
||||||
|TOTAL user+sieve |1.217.635 | 324.045 |358.761 |108.194 |299.485 | 94.611 |
|
|TOTAL user+sieve |1.217.635 | 324.360 |358.761 |108.351 |299.485 | 94.762 |
|
||||||
|TOTAL admin | 959.099 | 134.233 |292.911 | 48.455 |249.877 | 43.420 |
|
|TOTAL admin | 959.099 | 134.491 |292.911 | 48.601 |249.877 | 43.539 |
|
||||||
|
|
||||||
For a user its around 70% smaller and faster than traditional RainLoop.
|
For a user its around 70% smaller and faster than traditional RainLoop.
|
||||||
|
|
||||||
|
|
@ -189,8 +189,8 @@ For a user its around 70% smaller and faster than traditional RainLoop.
|
||||||
|
|
||||||
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
||||||
|------------ |-------: |------: |------: |------: |--------: |
|
|------------ |-------: |------: |------: |------: |--------: |
|
||||||
|app.css | 340.331 | 82.471 | 46.946 | 17.107 | 14.645 |
|
|app.css | 340.331 | 81.910 | 46.946 | 17.050 | 14.596 |
|
||||||
|app.min.css | 274.947 | 66.548 | 39.647 | 15.117 | 13.260 |
|
|app.min.css | 274.947 | 66.076 | 39.647 | 15.042 | 13.205 |
|
||||||
|boot.css | | 1.326 | | 664 | 545 |
|
|boot.css | | 1.326 | | 664 | 545 |
|
||||||
|boot.min.css | | 1.071 | | 590 | 474 |
|
|boot.min.css | | 1.071 | | 590 | 474 |
|
||||||
|admin.css | | 29.853 | | 6.812 | 5.933 |
|
|admin.css | | 29.853 | | 6.812 | 5.933 |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ foreach ($files as $fileinfo) {
|
||||||
$fileinfo->isDir() || unlink($fileinfo->getRealPath());
|
$fileinfo->isDir() || unlink($fileinfo->getRealPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$terser = ROOT_DIR . '/node_modules/terser/bin/terser';
|
||||||
|
|
||||||
$manifest = [];
|
$manifest = [];
|
||||||
require ROOT_DIR . '/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/AbstractPlugin.php';
|
require ROOT_DIR . '/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/AbstractPlugin.php';
|
||||||
$keys = [
|
$keys = [
|
||||||
|
|
@ -42,6 +44,21 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
||||||
$version = $manifest_item['version'];
|
$version = $manifest_item['version'];
|
||||||
if (0 < floatval($version)) {
|
if (0 < floatval($version)) {
|
||||||
echo "+ {$name} {$version}\n";
|
echo "+ {$name} {$version}\n";
|
||||||
|
|
||||||
|
// Minify JavaScript
|
||||||
|
foreach (glob("{$dir}/*.js") as $file) {
|
||||||
|
if (!strpos($file,'.min')) {
|
||||||
|
$mfile = str_replace('.js', '.min.js', $file);
|
||||||
|
passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (glob("{$dir}/js/*.js") as $file) {
|
||||||
|
if (!strpos($file,'.min')) {
|
||||||
|
$mfile = str_replace('.js', '.min.js', $file);
|
||||||
|
passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$manifest_item['type'] = 'plugin';
|
$manifest_item['type'] = 'plugin';
|
||||||
$manifest_item['id'] = $name;
|
$manifest_item['id'] = $name;
|
||||||
$manifest_item['file'] = "plugins/{$name}-{$version}.tgz";
|
$manifest_item['file'] = "plugins/{$name}-{$version}.tgz";
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,10 @@ export class AbstractModel {
|
||||||
case 'string':
|
case 'string':
|
||||||
this[key] = typeCast(this[key], value);
|
this[key] = typeCast(this[key], value);
|
||||||
break;
|
break;
|
||||||
// fall through
|
|
||||||
case 'undefined':
|
case 'undefined':
|
||||||
default:
|
|
||||||
this[key] = value;
|
this[key] = value;
|
||||||
|
// fall through
|
||||||
|
default:
|
||||||
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
|
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ export class AttachmentModel extends AbstractModel {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleChecked(self, event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
self.checked(!self.checked());
|
||||||
|
}
|
||||||
|
|
||||||
friendlySize() {
|
friendlySize() {
|
||||||
return FileInfo.friendlySize(this.estimatedSize) + (this.isLinked() ? ' 🔗' : '');
|
return FileInfo.friendlySize(this.estimatedSize) + (this.isLinked() ? ' 🔗' : '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export class ContactModel extends AbstractModel {
|
||||||
|
|
||||||
display: () => {
|
display: () => {
|
||||||
let a = this.fullName(),
|
let a = this.fullName(),
|
||||||
b = this.email()?.[0]?.value(),
|
b = this.email()[0]?.value(),
|
||||||
c = this.nickname();
|
c = this.nickname();
|
||||||
return a || b || c;
|
return a || b || c;
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ export class ContactModel extends AbstractModel {
|
||||||
*/
|
*/
|
||||||
getNameAndEmailHelper() {
|
getNameAndEmailHelper() {
|
||||||
let name = (this.givenName() + ' ' + this.surName()).trim(),
|
let name = (this.givenName() + ' ' + this.surName()).trim(),
|
||||||
email = this.email()[0];
|
email = this.email()[0]?.value();
|
||||||
/*
|
/*
|
||||||
// this.jCard.getOne('fn')?.notEmpty() ||
|
// this.jCard.getOne('fn')?.notEmpty() ||
|
||||||
this.jCard.parseFullName({set:true});
|
this.jCard.parseFullName({set:true});
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,10 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.folderListError('');
|
||||||
|
}
|
||||||
|
|
||||||
toggleFolderKolabType(folder, event) {
|
toggleFolderKolabType(folder, event) {
|
||||||
let type = event.target.value;
|
let type = event.target.value;
|
||||||
// TODO: append '.default' ?
|
// TODO: append '.default' ?
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTheme(theme) {
|
||||||
|
ThemeStore.theme(theme.name);
|
||||||
|
}
|
||||||
|
|
||||||
onBuild() {
|
onBuild() {
|
||||||
const currentTheme = ThemeStore.theme();
|
const currentTheme = ThemeStore.theme();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
|
|
||||||
@contacts-popup-left-width: 25%;
|
|
||||||
|
|
||||||
#V-PopupsContacts {
|
#V-PopupsContacts {
|
||||||
|
|
||||||
bottom: 0;
|
|
||||||
width: auto;
|
|
||||||
min-width: 550px;
|
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
max-height: 700px;
|
|
||||||
|
|
||||||
.control-group {
|
.control-group {
|
||||||
label.iconsize24 {
|
label.iconsize24 {
|
||||||
|
|
@ -18,32 +12,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
|
display: flex;
|
||||||
height: calc(100vh - 49px);
|
height: calc(100vh - 49px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
}
|
||||||
|
|
||||||
|
/* Left */
|
||||||
|
|
||||||
|
.left {
|
||||||
|
max-width: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-list-toolbar {
|
.b-list-toolbar {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: @contacts-popup-left-width;
|
border-bottom: 1px solid var(--border-color, #ddd);
|
||||||
border-bottom: 1px solid rgba(128,128,128,0.4);
|
|
||||||
|
|
||||||
.e-search {
|
.e-search {
|
||||||
margin: 7px 0 0;
|
margin: 7px 0 0;
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-list-footer-toolbar {
|
.b-list-footer-toolbar {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 46px;
|
height: 46px;
|
||||||
width: @contacts-popup-left-width;
|
border-top: 1px solid var(--border-color, #ddd);
|
||||||
background-color: var(--message-list-toolbar-bg-color,#eee);
|
|
||||||
box-shadow: inset 0 1px 0 #ccc;
|
|
||||||
|
|
||||||
.footer-pager {
|
.footer-pager {
|
||||||
padding: 8px 10px 0 0;
|
padding: 8px 10px 0 0;
|
||||||
|
|
@ -52,11 +46,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-list-content {
|
.b-list-content {
|
||||||
position: absolute;
|
height: calc(100% - 44px - 46px);
|
||||||
top: 45px;
|
|
||||||
bottom: 46px;
|
|
||||||
left: 0;
|
|
||||||
width: @contacts-popup-left-width;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
|
@ -87,9 +77,8 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 0;
|
|
||||||
border-left: 6px solid #eee;
|
border-left: 6px solid #eee;
|
||||||
border-bottom: 1px solid rgba(128,128,128,0.25);
|
border-bottom: 1px solid var(--border-color, #ddd);
|
||||||
|
|
||||||
&.focused {
|
&.focused {
|
||||||
border-left-color: #bbb;
|
border-left-color: #bbb;
|
||||||
|
|
@ -132,15 +121,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right */
|
||||||
|
|
||||||
|
.right {
|
||||||
|
width: 100%;
|
||||||
|
border-left: 1px solid var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
.b-view-content-toolbar {
|
.b-view-content-toolbar {
|
||||||
|
border-top: 1px solid var(--border-color, #ddd);
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: @contacts-popup-left-width;
|
|
||||||
height: 30px;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
border-top: 1px solid rgba(128,128,128,0.4);
|
|
||||||
|
|
||||||
.dropdown-menu.right-edge {
|
.dropdown-menu.right-edge {
|
||||||
top: auto;
|
top: auto;
|
||||||
|
|
@ -149,13 +140,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-view-content {
|
.b-view-content {
|
||||||
position: absolute;
|
height: calc(100% - 46px);
|
||||||
top: 0;
|
margin: 0;
|
||||||
bottom: 45px;
|
|
||||||
left: @contacts-popup-left-width;
|
|
||||||
right: 0;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-left: 1px solid rgba(128,128,128,0.25);
|
|
||||||
|
|
||||||
span {
|
span {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
@ -179,21 +166,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
input, textarea {
|
input, textarea {
|
||||||
box-shadow: none;
|
|
||||||
border-color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
width: 70vw;
|
width: 70vw;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
|
||||||
border-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
|
||||||
border-color: #999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,3 +201,9 @@ html:not(rl-mobile) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
#V-PopupsContacts {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
width: 128px;
|
min-width: 148px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: grey;
|
border-color: grey;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ export class AdminLoginView extends AbstractViewLogin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.submitError('');
|
||||||
|
}
|
||||||
|
|
||||||
submitCommand(self, event) {
|
submitCommand(self, event) {
|
||||||
let form = event.target.form,
|
let form = event.target.form,
|
||||||
data = new FormData(form),
|
data = new FormData(form),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export class AccountPopupView extends AbstractViewPopup {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.submitError('');
|
||||||
|
}
|
||||||
|
|
||||||
submitForm(form) {
|
submitForm(form) {
|
||||||
if (!this.submitRequest() && form.reportValidity()) {
|
if (!this.submitRequest() && form.reportValidity()) {
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,10 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.submitError('');
|
||||||
|
}
|
||||||
|
|
||||||
showError(e) {
|
showError(e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
if (e?.message) {
|
if (e?.message) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ export class PluginPopupView extends AbstractViewPopup {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.saveError('');
|
||||||
|
}
|
||||||
|
|
||||||
saveCommand() {
|
saveCommand() {
|
||||||
const oConfig = {
|
const oConfig = {
|
||||||
Id: this.id(),
|
Id: this.id(),
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,14 @@ export class LoginUserView extends AbstractViewLogin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
this.submitError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSignMe() {
|
||||||
|
this.signMe(!this.signMe());
|
||||||
|
}
|
||||||
|
|
||||||
submitCommand(self, event) {
|
submitCommand(self, event) {
|
||||||
const email = this.email().trim();
|
const email = this.email().trim();
|
||||||
this.email(email);
|
this.email(email);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { leftPanelDisabled, toggleLeftPanel,
|
||||||
addShortcut, registerShortcut, formFieldFocused
|
addShortcut, registerShortcut, formFieldFocused
|
||||||
} from 'Common/Globals';
|
} from 'Common/Globals';
|
||||||
|
|
||||||
|
import { arrayLength } from 'Common/Utils';
|
||||||
import { computedPaginatorHelper, showMessageComposer, populateMessageBody, download, moveAction } from 'Common/UtilsUser';
|
import { computedPaginatorHelper, showMessageComposer, populateMessageBody, download, moveAction } from 'Common/UtilsUser';
|
||||||
import { FileInfo } from 'Common/File';
|
import { FileInfo } from 'Common/File';
|
||||||
import { isFullscreen, toggleFullscreen } from 'Common/Fullscreen';
|
import { isFullscreen, toggleFullscreen } from 'Common/Fullscreen';
|
||||||
|
|
@ -102,6 +103,9 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
this.dragOver = ko.observable(false).extend({ throttle: 1 });
|
this.dragOver = ko.observable(false).extend({ throttle: 1 });
|
||||||
this.dragOverEnter = ko.observable(false).extend({ throttle: 1 });
|
this.dragOverEnter = ko.observable(false).extend({ throttle: 1 });
|
||||||
|
|
||||||
|
const attachmentsActions = Settings.app('attachmentsActions');
|
||||||
|
this.attachmentsActions = ko.observableArray(arrayLength(attachmentsActions) ? attachmentsActions : []);
|
||||||
|
|
||||||
addComputablesTo(this, {
|
addComputablesTo(this, {
|
||||||
|
|
||||||
sortSupported: () =>
|
sortSupported: () =>
|
||||||
|
|
@ -148,7 +152,9 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
return '𝐒' + (desc ? '⬆' : '⬇');
|
return '𝐒' + (desc ? '⬆' : '⬇');
|
||||||
}
|
}
|
||||||
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆');
|
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆');
|
||||||
}
|
},
|
||||||
|
|
||||||
|
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
|
||||||
});
|
});
|
||||||
|
|
||||||
this.selector = new Selector(
|
this.selector = new Selector(
|
||||||
|
|
@ -259,7 +265,8 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
).throttle(50));
|
).throttle(50));
|
||||||
|
|
||||||
decorateKoCommands(this, {
|
decorateKoCommands(this, {
|
||||||
downloadCommand: canBeMovedHelper,
|
downloadAttachCommand: canBeMovedHelper,
|
||||||
|
downloadZipCommand: canBeMovedHelper,
|
||||||
forwardCommand: canBeMovedHelper,
|
forwardCommand: canBeMovedHelper,
|
||||||
deleteWithoutMoveCommand: canBeMovedHelper,
|
deleteWithoutMoveCommand: canBeMovedHelper,
|
||||||
deleteCommand: canBeMovedHelper,
|
deleteCommand: canBeMovedHelper,
|
||||||
|
|
@ -292,7 +299,30 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCommand() {
|
downloadZipCommand() {
|
||||||
|
let hashes = []/*, uids = []*/;
|
||||||
|
// MessagelistUserStore.forEach(message => message.checked() && uids.push(message.uid));
|
||||||
|
MessagelistUserStore.forEach(message => message.checked() && hashes.push(message.requestHash));
|
||||||
|
if (hashes.length) {
|
||||||
|
Remote.post('AttachmentsActions', null, {
|
||||||
|
Do: 'Zip',
|
||||||
|
Folder: MessagelistUserStore().Folder,
|
||||||
|
// Uids: uids,
|
||||||
|
Hashes: hashes
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
let hash = result?.Result?.FileHash;
|
||||||
|
if (hash) {
|
||||||
|
download(attachmentDownload(hash), hash+'.zip');
|
||||||
|
} else {
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => alert('Download failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAttachCommand() {
|
||||||
let hashes = [];
|
let hashes = [];
|
||||||
MessagelistUserStore.forEach(message => {
|
MessagelistUserStore.forEach(message => {
|
||||||
if (message.checked()) {
|
if (message.checked()) {
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,10 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleFullInfo() {
|
||||||
|
this.showFullInfo(!this.showFullInfo());
|
||||||
|
}
|
||||||
|
|
||||||
closeMessage() {
|
closeMessage() {
|
||||||
currentMessage(null);
|
currentMessage(null);
|
||||||
}
|
}
|
||||||
|
|
@ -393,9 +397,7 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
|
|
||||||
// message information
|
// message information
|
||||||
registerShortcut('i', 'meta', [Scope.MessageList, Scope.MessageView], () => {
|
registerShortcut('i', 'meta', [Scope.MessageList, Scope.MessageView], () => {
|
||||||
if (currentMessage()) {
|
currentMessage() && this.toggleFullInfo();
|
||||||
this.showFullInfo(!this.showFullInfo());
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
This app packages SnappyMail <upstream>2.22.3</upstream>.
|
This app packages SnappyMail <upstream>2.22.4</upstream>.
|
||||||
|
|
||||||
SnappyMail is a simple, modern, lightweight & fast web-based email client.
|
SnappyMail is a simple, modern, lightweight & fast web-based email client.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ RUN mkdir -p /app/code
|
||||||
WORKDIR /app/code
|
WORKDIR /app/code
|
||||||
|
|
||||||
# If you change the extraction below, be sure to test on scaleway
|
# If you change the extraction below, be sure to test on scaleway
|
||||||
VERSION=2.22.3
|
VERSION=2.22.4
|
||||||
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
|
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
|
||||||
unzip /tmp/snappymail.zip -d /app/code && \
|
unzip /tmp/snappymail.zip -d /app/code && \
|
||||||
rm /tmp/snappymail.zip && \
|
rm /tmp/snappymail.zip && \
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<id>snappymail</id>
|
<id>snappymail</id>
|
||||||
<name>SnappyMail</name>
|
<name>SnappyMail</name>
|
||||||
<summary>SnappyMail Webmail</summary>
|
<summary>SnappyMail Webmail</summary>
|
||||||
<version>2.22.3</version>
|
<version>2.22.4</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author>
|
<author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author>
|
||||||
<description><![CDATA[**Simple, modern, lightweight & fast web-based email client.**
|
<description><![CDATA[**Simple, modern, lightweight & fast web-based email client.**
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ return "SnappyMail Webmail is a browser-based multilingual IMAP client with an a
|
||||||
# script_snappymail_versions()
|
# script_snappymail_versions()
|
||||||
sub script_snappymail_versions
|
sub script_snappymail_versions
|
||||||
{
|
{
|
||||||
return ( "2.22.3" );
|
return ( "2.22.4" );
|
||||||
}
|
}
|
||||||
|
|
||||||
sub script_snappymail_version_desc
|
sub script_snappymail_version_desc
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"title": "SnappyMail",
|
"title": "SnappyMail",
|
||||||
"description": "Simple, modern & fast web-based email client",
|
"description": "Simple, modern & fast web-based email client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.22.3",
|
"version": "2.22.4",
|
||||||
"homepage": "https://snappymail.eu",
|
"homepage": "https://snappymail.eu",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "DJ Maze",
|
"name": "DJ Maze",
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,7 @@ $Plugin->addHook('hook.name', 'functionName');
|
||||||
Happens before send/save message
|
Happens before send/save message
|
||||||
|
|
||||||
### filter.message-rcpt
|
### filter.message-rcpt
|
||||||
|
Called by DoSendMessage and DoSendReadReceiptMessage
|
||||||
params:
|
params:
|
||||||
\RainLoop\Model\Account $oAccount
|
\RainLoop\Model\Account $oAccount
|
||||||
\MailSo\Mime\EmailCollection $oRcpt
|
\MailSo\Mime\EmailCollection $oRcpt
|
||||||
|
|
@ -359,6 +360,7 @@ $Plugin->addHook('hook.name', 'functionName');
|
||||||
array &$aHiddenRcpt
|
array &$aHiddenRcpt
|
||||||
|
|
||||||
### filter.smtp-message-stream
|
### filter.smtp-message-stream
|
||||||
|
Called by DoSendMessage and DoSendReadReceiptMessage
|
||||||
params:
|
params:
|
||||||
\RainLoop\Model\Account $oAccount
|
\RainLoop\Model\Account $oAccount
|
||||||
resource &$rMessageStream
|
resource &$rMessageStream
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
(rl => {
|
(rl => {
|
||||||
|
|
||||||
const
|
const
|
||||||
queue = [],
|
queue = [],
|
||||||
avatars = new Map,
|
avatars = new Map,
|
||||||
|
ncAvatars = new Map,
|
||||||
templateId = 'MailMessageView',
|
templateId = 'MailMessageView',
|
||||||
getAvatarUid = msg => {
|
getAvatarUid = msg => {
|
||||||
let from = msg.from[0],
|
let from = msg.from[0],
|
||||||
bimi = 'pass' == from.dkimStatus ? 1 : 0;
|
bimi = 'pass' == from.dkimStatus ? 1 : 0;
|
||||||
return `${bimi}/${from.email.toLowerCase()}`;
|
return `${bimi}/${from.email.toLowerCase()}`;
|
||||||
},
|
},
|
||||||
getAvatar = msg => avatars.get(getAvatarUid(msg)),
|
getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(msg)),
|
||||||
runQueue = (() => {
|
runQueue = (() => {
|
||||||
let item = queue.shift();
|
let item = queue.shift();
|
||||||
while (item) {
|
while (item) {
|
||||||
|
|
@ -17,15 +19,18 @@
|
||||||
item[1](url);
|
item[1](url);
|
||||||
item = queue.shift();
|
item = queue.shift();
|
||||||
} else {
|
} else {
|
||||||
// TODO: fetch vCard from Nextcloud contacts
|
|
||||||
// let cfg = rl.settings.get('Nextcloud'),
|
|
||||||
|
|
||||||
let from = item[0].from[0];
|
let from = item[0].from[0];
|
||||||
rl.pluginRemoteRequest((iError, data) => {
|
rl.pluginRemoteRequest((iError, data) => {
|
||||||
if (!iError && data?.Result.type) {
|
if (!iError && data?.Result.type) {
|
||||||
url = `data:${data.Result.type};base64,${data.Result.data}`;
|
url = `data:${data.Result.type};base64,${data.Result.data}`;
|
||||||
avatars.set(getAvatarUid(item[0]), url);
|
avatars.set(getAvatarUid(item[0]), url);
|
||||||
item[1](url);
|
item[1](url);
|
||||||
|
} else if (window.identiconSvg) {
|
||||||
|
// rl.pluginSettingsGet('avatars', 'identicon');
|
||||||
|
window.identiconSvg(from.email).then(url => {
|
||||||
|
avatars.set(getAvatarUid(item[0]), url);
|
||||||
|
item[1](url);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
runQueue();
|
runQueue();
|
||||||
}, 'Avatar', {
|
}, 'Avatar', {
|
||||||
|
|
@ -37,6 +42,58 @@
|
||||||
}
|
}
|
||||||
}).debounce(1000);
|
}).debounce(1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads images from Nextcloud contacts
|
||||||
|
*/
|
||||||
|
addEventListener('DOMContentLoaded', () => {
|
||||||
|
// rl.pluginSettingsGet('avatars', 'nextcloud');
|
||||||
|
if (parent.OC) {
|
||||||
|
const OC = () => parent.OC,
|
||||||
|
nsDAV = 'DAV:',
|
||||||
|
nsNC = 'http://nextcloud.com/ns',
|
||||||
|
nsCard = 'urn:ietf:params:xml:ns:carddav',
|
||||||
|
getElementsByTagName = (parent, namespace, localName) => parent.getElementsByTagNameNS(namespace, localName),
|
||||||
|
getElementValue = (parent, namespace, localName) =>
|
||||||
|
getElementsByTagName(parent, namespace, localName)?.item(0)?.textContent,
|
||||||
|
generateUrl = path => OC().webroot + '/remote.php' + path;
|
||||||
|
if (OC().requestToken) {
|
||||||
|
fetch(generateUrl(`/dav/addressbooks/users/${OC().currentUser}/contacts/`), {
|
||||||
|
mode: 'same-origin',
|
||||||
|
cache: 'no-cache',
|
||||||
|
redirect: 'error',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
method: 'REPORT',
|
||||||
|
headers: {
|
||||||
|
requesttoken: OC().requestToken,
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
Depth: 1
|
||||||
|
},
|
||||||
|
body: '<x4:addressbook-query xmlns:x4="urn:ietf:params:xml:ns:carddav"><x0:prop xmlns:x0="DAV:"><x4:address-data><x4:prop name="EMAIL"/></x4:address-data><x3:has-photo xmlns:x3="http://nextcloud.com/ns"/></x0:prop></x4:addressbook-query>'
|
||||||
|
})
|
||||||
|
.then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response })))
|
||||||
|
.then(text => {
|
||||||
|
const
|
||||||
|
xmlParser = new DOMParser(),
|
||||||
|
responseList = getElementsByTagName(
|
||||||
|
xmlParser.parseFromString(text, 'application/xml').documentElement,
|
||||||
|
nsDAV,
|
||||||
|
'response');
|
||||||
|
for (let i = 0; i < responseList.length; ++i) {
|
||||||
|
const item = responseList.item(i);
|
||||||
|
if (1 == getElementValue(item, nsNC, 'has-photo')) {
|
||||||
|
[...getElementValue(item, nsCard, 'address-data').matchAll(/EMAIL.*?:([^@\r\n]+@[^@\r\n]+)/g)].forEach(match => {
|
||||||
|
ncAvatars.set(
|
||||||
|
match[1].toLowerCase(),
|
||||||
|
getElementValue(item, nsDAV, 'href') + '?photo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ko.bindingHandlers.fromPic = {
|
ko.bindingHandlers.fromPic = {
|
||||||
init: (element, self, dummy, msg) => {
|
init: (element, self, dummy, msg) => {
|
||||||
if (msg) {
|
if (msg) {
|
||||||
|
|
@ -46,6 +103,10 @@
|
||||||
fn(url);
|
fn(url);
|
||||||
} else if (msg.avatar) {
|
} else if (msg.avatar) {
|
||||||
let bimi = 'pass' == msg.from[0].dkimStatus ? 1 : 0;
|
let bimi = 'pass' == msg.from[0].dkimStatus ? 1 : 0;
|
||||||
|
if (window.identiconSvg) {
|
||||||
|
// rl.pluginSettingsGet('avatars', 'identicon');
|
||||||
|
element.onerror = () => window.identiconSvg(msg.from[0].email).then(fn);
|
||||||
|
}
|
||||||
fn(`?Avatar/${bimi}/${msg.avatar}`);
|
fn(`?Avatar/${bimi}/${msg.avatar}`);
|
||||||
} else {
|
} else {
|
||||||
queue.push([msg, fn]);
|
queue.push([msg, fn]);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
NAME = 'Avatars',
|
NAME = 'Avatars',
|
||||||
AUTHOR = 'SnappyMail',
|
AUTHOR = 'SnappyMail',
|
||||||
URL = 'https://snappymail.eu/',
|
URL = 'https://snappymail.eu/',
|
||||||
VERSION = '1.1',
|
VERSION = '1.2',
|
||||||
RELEASE = '2022-11-27',
|
RELEASE = '2022-11-29',
|
||||||
REQUIRED = '2.22.4',
|
REQUIRED = '2.22.4',
|
||||||
CATEGORY = 'Contacts',
|
CATEGORY = 'Contacts',
|
||||||
LICENSE = 'MIT',
|
LICENSE = 'MIT',
|
||||||
|
|
@ -19,9 +19,9 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$this->addJs('avatars.js');
|
$this->addJs('avatars.js');
|
||||||
$this->addJsonHook('Avatar', 'DoAvatar');
|
$this->addJsonHook('Avatar', 'DoAvatar');
|
||||||
$this->addPartHook('Avatar', 'ServiceAvatar');
|
$this->addPartHook('Avatar', 'ServiceAvatar');
|
||||||
|
$this->Config()->Get('plugin', 'identicon', false) && $this->addJs('jdenticon.js');
|
||||||
// https://github.com/the-djmaze/snappymail/issues/714
|
// https://github.com/the-djmaze/snappymail/issues/714
|
||||||
$this->Config()->Get('plugin', 'delay', true)
|
$this->Config()->Get('plugin', 'delay', true) || $this->addHook('filter.json-response', 'FilterJsonResponse');
|
||||||
|| $this->addHook('filter.json-response', 'FilterJsonResponse');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function FilterJsonResponse(string $sAction, array &$aResponseItem)
|
public function FilterJsonResponse(string $sAction, array &$aResponseItem)
|
||||||
|
|
@ -73,24 +73,41 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($sBimi)))) {
|
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($sBimi)))) {
|
||||||
\header('Content-Type: '.$aResult[0]);
|
\header('Content-Type: '.$aResult[0]);
|
||||||
echo $aResult[1];
|
echo $aResult[1];
|
||||||
return true;
|
} else {
|
||||||
|
\MailSo\Base\Http::StatusHeader(404);
|
||||||
}
|
}
|
||||||
return false;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configMapping() : array
|
protected function configMapping() : array
|
||||||
{
|
{
|
||||||
return array(
|
$aResult = array(
|
||||||
\RainLoop\Plugins\Property::NewInstance('delay')->SetLabel('Delay loading')
|
\RainLoop\Plugins\Property::NewInstance('delay')->SetLabel('Delay loading')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
->SetDefaultValue(true),
|
->SetDefaultValue(true),
|
||||||
\RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('Use BIMI (https://bimigroup.org/)')
|
\RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('Lookup BIMI')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
|
||||||
->SetDefaultValue(false),
|
|
||||||
\RainLoop\Plugins\Property::NewInstance('gravatar')->SetLabel('Use Gravatar')
|
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
->SetDefaultValue(false)
|
->SetDefaultValue(false)
|
||||||
|
->SetDescription('https://bimigroup.org/'),
|
||||||
|
\RainLoop\Plugins\Property::NewInstance('gravatar')->SetLabel('Lookup Gravatar')
|
||||||
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
|
->SetDefaultValue(false)
|
||||||
|
->SetDescription('https://wikipedia.org/wiki/Gravatar')
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
|
if (\class_exists('OC') && isset(\OC::$server)) {
|
||||||
|
$aResult[] = \RainLoop\Plugins\Property::NewInstance('nextcloud')->SetLabel('Lookup Nextcloud Contacts')
|
||||||
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
|
// ->SetAllowedInJs(true)
|
||||||
|
->SetDefaultValue(false);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
$aResult[] = \RainLoop\Plugins\Property::NewInstance('identicon')->SetLabel('Else create Identicon')
|
||||||
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
|
// ->SetAllowedInJs(true)
|
||||||
|
->SetDefaultValue(false)
|
||||||
|
->SetDescription('https://wikipedia.org/wiki/Identicon');
|
||||||
|
return $aResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAvatar(string $sEmail, bool $bBimi) : ?array
|
private function getAvatar(string $sEmail, bool $bBimi) : ?array
|
||||||
|
|
@ -150,7 +167,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
if ($BIMI) {
|
if ($BIMI) {
|
||||||
$aUrls[] = $BIMI;
|
$aUrls[] = $BIMI;
|
||||||
// $aResult = ['text/uri-list', $BIMI];
|
// $aResult = ['text/uri-list', $BIMI];
|
||||||
\SnappyMail\Log::debug('Avatar', "BIMI {$sDomain} for {$sUrl}");
|
\SnappyMail\Log::debug('Avatar', "BIMI {$sDomain}: {$BIMI}");
|
||||||
} else {
|
} else {
|
||||||
\SnappyMail\Log::notice('Avatar', "BIMI 404 for {$sDomain}");
|
\SnappyMail\Log::notice('Avatar', "BIMI 404 for {$sDomain}");
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +199,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$aServices = [
|
$aServices = [
|
||||||
"services/{$sDomain}",
|
"services/{$sDomain}",
|
||||||
'services/' . \preg_replace('/^.+\\.([^.]+\\.[^.]+)$/D', '$1', $sDomain),
|
'services/' . \preg_replace('/^.+\\.([^.]+\\.[^.]+)$/D', '$1', $sDomain),
|
||||||
'services/' . \preg_replace('/^(.+\\.)?(paypal\\.[a-z][a-z])$/D', 'paypal.com', $sDomain),
|
'services/' . \preg_replace('/^(.+\\.)?(paypal\\.[a-z][a-z])$/D', 'paypal.com', $sDomain)
|
||||||
'empty-contact' // DATA_IMAGE_USER_DOT_PIC
|
|
||||||
];
|
];
|
||||||
|
if (!$this->Config()->Get('plugin', 'identicon', false)) {
|
||||||
|
$aServices[] = 'empty-contact'; // DATA_IMAGE_USER_DOT_PIC
|
||||||
|
}
|
||||||
foreach ($aServices as $service) {
|
foreach ($aServices as $service) {
|
||||||
$file = __DIR__ . "/images/{$service}.png";
|
$file = __DIR__ . "/images/{$service}.png";
|
||||||
if (\file_exists($file)) {
|
if (\file_exists($file)) {
|
||||||
|
|
|
||||||
694
plugins/avatars/jdenticon.js
Normal file
694
plugins/avatars/jdenticon.js
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
/**
|
||||||
|
* Jdenticon 3.2.0
|
||||||
|
* http://jdenticon.com
|
||||||
|
*
|
||||||
|
* Built: 2022-08-07T11:23:11.640Z
|
||||||
|
*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2014-2021 Daniel Mester Pirttijärvi
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(()=>{
|
||||||
|
/**
|
||||||
|
* Parses a substring of the hash as a number.
|
||||||
|
* @param {number} startPosition
|
||||||
|
* @param {number=} octets
|
||||||
|
*/
|
||||||
|
|
||||||
|
class HSLColor
|
||||||
|
{
|
||||||
|
constructor(h, s, l)
|
||||||
|
{
|
||||||
|
this.h = h;
|
||||||
|
this.s = s;
|
||||||
|
this.l = l;
|
||||||
|
// this.a = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString()
|
||||||
|
{
|
||||||
|
const h = this.h, s = this.s, l = this.l/*, a = this.a*/,
|
||||||
|
hex = v => {
|
||||||
|
v = between(0, 255, v * 255).toString(16);
|
||||||
|
return v.slice(0,2).padStart(2, "0");
|
||||||
|
};
|
||||||
|
let r=l, g=l, b=l;
|
||||||
|
if (0 != s) {
|
||||||
|
let q=l<0.5?l*(s+1):l+s-l*s,
|
||||||
|
p=l*2-q,
|
||||||
|
hue2rgb = h => {
|
||||||
|
h += h<0 ? 1 : (h>1 ? -1 : 0);
|
||||||
|
return (h*6<1) ? (p+(q-p)*h*6) : ((h*2<1) ? q : ((h*3<2) ? p+(q-p)*(2/3-h)*6 : p));
|
||||||
|
};
|
||||||
|
r = hue2rgb(h+1/3);
|
||||||
|
g = hue2rgb(h);
|
||||||
|
b = hue2rgb(h-1/3);
|
||||||
|
}
|
||||||
|
return '#' + hex(r) + hex(g) + hex(b) // isNaN(a)?100:a*100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const
|
||||||
|
between = (l, h, v) => Math.max(l, Math.min(h, v)),
|
||||||
|
|
||||||
|
parseHex = (hash, startPosition, octets) => parseInt(hash.substr(startPosition, octets), 16),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an HSL color to a hexadecimal RGB color. This function will correct the lightness for the "dark" hues
|
||||||
|
* @param {number} hue Hue in range [0, 1]
|
||||||
|
* @param {number} saturation Saturation in range [0, 1]
|
||||||
|
* @param {number} lightness Lightness in range [0, 1]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
correctedHsl = (hue, saturation, lightness) => {
|
||||||
|
// The corrector specifies the perceived middle lightness for each hue
|
||||||
|
var correctors = [ 0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55 ],
|
||||||
|
corrector = correctors[(hue * 6 + 0.5) | 0];
|
||||||
|
|
||||||
|
// Adjust the input lightness relative to the corrector
|
||||||
|
lightness = lightness < 0.5 ? lightness * corrector * 2 : corrector + (lightness - 0.5) * (1 - corrector) * 2;
|
||||||
|
|
||||||
|
return new HSLColor(hue, saturation, lightness).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a point.
|
||||||
|
*/
|
||||||
|
class Point
|
||||||
|
{
|
||||||
|
constructor(x, y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates and rotates a point before being passed on to the canvas context. This was previously done by the canvas context itself,
|
||||||
|
* but this caused a rendering issue in Chrome on sizes > 256 where the rotation transformation of inverted paths was not done properly.
|
||||||
|
*/
|
||||||
|
class Transform
|
||||||
|
{
|
||||||
|
constructor(x, y, size, rotation) {
|
||||||
|
this.u/*_x*/ = x;
|
||||||
|
this.v/*_y*/ = y;
|
||||||
|
this.K/*_size*/ = size;
|
||||||
|
this.Z/*_rotation*/ = rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms the specified point based on the translation and rotation specification for this Transform.
|
||||||
|
* @param {number} x x-coordinate
|
||||||
|
* @param {number} y y-coordinate
|
||||||
|
* @param {number=} w The width of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle.
|
||||||
|
* @param {number=} h The height of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle.
|
||||||
|
*/
|
||||||
|
L/*transformIconPoint*/(x, y, w, h) {
|
||||||
|
var right = this.u/*_x*/ + this.K/*_size*/,
|
||||||
|
bottom = this.v/*_y*/ + this.K/*_size*/,
|
||||||
|
rotation = this.Z/*_rotation*/;
|
||||||
|
return rotation === 1 ? new Point(right - y - (h || 0), this.v/*_y*/ + x) :
|
||||||
|
rotation === 2 ? new Point(right - x - (w || 0), bottom - y - (h || 0)) :
|
||||||
|
rotation === 3 ? new Point(this.u/*_x*/ + y, bottom - x - (w || 0)) :
|
||||||
|
new Point(this.u/*_x*/ + x, this.v/*_y*/ + y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var NO_TRANSFORM = new Transform(0, 0, 0, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides helper functions for rendering common basic shapes.
|
||||||
|
*/
|
||||||
|
class Graphics
|
||||||
|
{
|
||||||
|
constructor(renderer) {
|
||||||
|
/**
|
||||||
|
* @type {Renderer}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.M/*_renderer*/ = renderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Transform}
|
||||||
|
*/
|
||||||
|
this.A/*currentTransform*/ = NO_TRANSFORM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a polygon to the underlying renderer.
|
||||||
|
* @param {Array<number>} points The points of the polygon clockwise on the format [ x0, y0, x1, y1, ..., xn, yn ]
|
||||||
|
* @param {boolean=} invert Specifies if the polygon will be inverted.
|
||||||
|
*/
|
||||||
|
g/*addPolygon*/(points, invert) {
|
||||||
|
var this$1 = this;
|
||||||
|
|
||||||
|
var di = invert ? -2 : 2,
|
||||||
|
transformedPoints = [];
|
||||||
|
|
||||||
|
for (var i = invert ? points.length - 2 : 0; i < points.length && i >= 0; i += di) {
|
||||||
|
transformedPoints.push(this$1.A/*currentTransform*/.L/*transformIconPoint*/(points[i], points[i + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.M/*_renderer*/.g/*addPolygon*/(transformedPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a polygon to the underlying renderer.
|
||||||
|
* Source: http://stackoverflow.com/a/2173084
|
||||||
|
* @param {number} x The x-coordinate of the upper left corner of the rectangle holding the entire ellipse.
|
||||||
|
* @param {number} y The y-coordinate of the upper left corner of the rectangle holding the entire ellipse.
|
||||||
|
* @param {number} size The size of the ellipse.
|
||||||
|
* @param {boolean=} invert Specifies if the ellipse will be inverted.
|
||||||
|
*/
|
||||||
|
h/*addCircle*/(x, y, size, invert) {
|
||||||
|
var p = this.A/*currentTransform*/.L/*transformIconPoint*/(x, y, size, size);
|
||||||
|
this.M/*_renderer*/.h/*addCircle*/(p, size, invert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rectangle to the underlying renderer.
|
||||||
|
* @param {number} x The x-coordinate of the upper left corner of the rectangle.
|
||||||
|
* @param {number} y The y-coordinate of the upper left corner of the rectangle.
|
||||||
|
* @param {number} w The width of the rectangle.
|
||||||
|
* @param {number} h The height of the rectangle.
|
||||||
|
* @param {boolean=} invert Specifies if the rectangle will be inverted.
|
||||||
|
*/
|
||||||
|
i/*addRectangle*/(x, y, w, h, invert) {
|
||||||
|
this.g/*addPolygon*/([
|
||||||
|
x, y,
|
||||||
|
x + w, y,
|
||||||
|
x + w, y + h,
|
||||||
|
x, y + h
|
||||||
|
], invert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a right triangle to the underlying renderer.
|
||||||
|
* @param {number} x The x-coordinate of the upper left corner of the rectangle holding the triangle.
|
||||||
|
* @param {number} y The y-coordinate of the upper left corner of the rectangle holding the triangle.
|
||||||
|
* @param {number} w The width of the triangle.
|
||||||
|
* @param {number} h The height of the triangle.
|
||||||
|
* @param {number} r The rotation of the triangle (clockwise). 0 = right corner of the triangle in the lower left corner of the bounding rectangle.
|
||||||
|
* @param {boolean=} invert Specifies if the triangle will be inverted.
|
||||||
|
*/
|
||||||
|
j/*addTriangle*/(x, y, w, h, r, invert) {
|
||||||
|
var points = [
|
||||||
|
x + w, y,
|
||||||
|
x + w, y + h,
|
||||||
|
x, y + h,
|
||||||
|
x, y
|
||||||
|
];
|
||||||
|
points.splice(((r || 0) % 4) * 2, 2);
|
||||||
|
this.g/*addPolygon*/(points, invert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rhombus to the underlying renderer.
|
||||||
|
* @param {number} x The x-coordinate of the upper left corner of the rectangle holding the rhombus.
|
||||||
|
* @param {number} y The y-coordinate of the upper left corner of the rectangle holding the rhombus.
|
||||||
|
* @param {number} w The width of the rhombus.
|
||||||
|
* @param {number} h The height of the rhombus.
|
||||||
|
* @param {boolean=} invert Specifies if the rhombus will be inverted.
|
||||||
|
*/
|
||||||
|
N/*addRhombus*/(x, y, w, h, invert) {
|
||||||
|
this.g/*addPolygon*/([
|
||||||
|
x + w / 2, y,
|
||||||
|
x + w, y + h / 2,
|
||||||
|
x + w / 2, y + h,
|
||||||
|
x, y + h / 2
|
||||||
|
], invert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} index
|
||||||
|
* @param {Graphics} g
|
||||||
|
* @param {number} cell
|
||||||
|
* @param {number} positionIndex
|
||||||
|
*/
|
||||||
|
function centerShape(index, g, cell, positionIndex) {
|
||||||
|
index = index % 14;
|
||||||
|
|
||||||
|
var k, m, w, h, inner, outer;
|
||||||
|
|
||||||
|
!index ? (
|
||||||
|
k = cell * 0.42,
|
||||||
|
g.g/*addPolygon*/([
|
||||||
|
0, 0,
|
||||||
|
cell, 0,
|
||||||
|
cell, cell - k * 2,
|
||||||
|
cell - k, cell,
|
||||||
|
0, cell
|
||||||
|
])) :
|
||||||
|
|
||||||
|
index == 1 ? (
|
||||||
|
w = 0 | (cell * 0.5),
|
||||||
|
h = 0 | (cell * 0.8),
|
||||||
|
|
||||||
|
g.j/*addTriangle*/(cell - w, 0, w, h, 2)) :
|
||||||
|
|
||||||
|
index == 2 ? (
|
||||||
|
w = 0 | (cell / 3),
|
||||||
|
g.i/*addRectangle*/(w, w, cell - w, cell - w)) :
|
||||||
|
|
||||||
|
index == 3 ? (
|
||||||
|
inner = cell * 0.1,
|
||||||
|
// Use fixed outer border widths in small icons to ensure the border is drawn
|
||||||
|
outer =
|
||||||
|
cell < 6 ? 1 :
|
||||||
|
cell < 8 ? 2 :
|
||||||
|
(0 | (cell * 0.25)),
|
||||||
|
|
||||||
|
inner =
|
||||||
|
inner > 1 ? (0 | inner) : // large icon => truncate decimals
|
||||||
|
inner > 0.5 ? 1 : // medium size icon => fixed width
|
||||||
|
inner, // small icon => anti-aliased border
|
||||||
|
|
||||||
|
g.i/*addRectangle*/(outer, outer, cell - inner - outer, cell - inner - outer)) :
|
||||||
|
|
||||||
|
index == 4 ? (
|
||||||
|
m = 0 | (cell * 0.15),
|
||||||
|
w = 0 | (cell * 0.5),
|
||||||
|
g.h/*addCircle*/(cell - w - m, cell - w - m, w)) :
|
||||||
|
|
||||||
|
index == 5 ? (
|
||||||
|
inner = cell * 0.1,
|
||||||
|
outer = inner * 4,
|
||||||
|
|
||||||
|
// Align edge to nearest pixel in large icons
|
||||||
|
outer > 3 && (outer = 0 | outer),
|
||||||
|
|
||||||
|
g.i/*addRectangle*/(0, 0, cell, cell),
|
||||||
|
g.g/*addPolygon*/([
|
||||||
|
outer, outer,
|
||||||
|
cell - inner, outer,
|
||||||
|
outer + (cell - outer - inner) / 2, cell - inner
|
||||||
|
], true)) :
|
||||||
|
|
||||||
|
index == 6 ?
|
||||||
|
g.g/*addPolygon*/([
|
||||||
|
0, 0,
|
||||||
|
cell, 0,
|
||||||
|
cell, cell * 0.7,
|
||||||
|
cell * 0.4, cell * 0.4,
|
||||||
|
cell * 0.7, cell,
|
||||||
|
0, cell
|
||||||
|
]) :
|
||||||
|
|
||||||
|
index == 7 ?
|
||||||
|
g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 3) :
|
||||||
|
|
||||||
|
index == 8 ? (
|
||||||
|
g.i/*addRectangle*/(0, 0, cell, cell / 2),
|
||||||
|
g.i/*addRectangle*/(0, cell / 2, cell / 2, cell / 2),
|
||||||
|
g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 1)) :
|
||||||
|
|
||||||
|
index == 9 ? (
|
||||||
|
inner = cell * 0.14,
|
||||||
|
// Use fixed outer border widths in small icons to ensure the border is drawn
|
||||||
|
outer =
|
||||||
|
cell < 4 ? 1 :
|
||||||
|
cell < 6 ? 2 :
|
||||||
|
(0 | (cell * 0.35)),
|
||||||
|
|
||||||
|
inner =
|
||||||
|
cell < 8 ? inner : // small icon => anti-aliased border
|
||||||
|
(0 | inner), // large icon => truncate decimals
|
||||||
|
|
||||||
|
g.i/*addRectangle*/(0, 0, cell, cell),
|
||||||
|
g.i/*addRectangle*/(outer, outer, cell - outer - inner, cell - outer - inner, true)) :
|
||||||
|
|
||||||
|
index == 10 ? (
|
||||||
|
inner = cell * 0.12,
|
||||||
|
outer = inner * 3,
|
||||||
|
|
||||||
|
g.i/*addRectangle*/(0, 0, cell, cell),
|
||||||
|
g.h/*addCircle*/(outer, outer, cell - inner - outer, true)) :
|
||||||
|
|
||||||
|
index == 11 ?
|
||||||
|
g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 3) :
|
||||||
|
|
||||||
|
index == 12 ? (
|
||||||
|
m = cell * 0.25,
|
||||||
|
g.i/*addRectangle*/(0, 0, cell, cell),
|
||||||
|
g.N/*addRhombus*/(m, m, cell - m, cell - m, true)) :
|
||||||
|
|
||||||
|
// 13
|
||||||
|
(
|
||||||
|
!positionIndex && (
|
||||||
|
m = cell * 0.4, w = cell * 1.2,
|
||||||
|
g.h/*addCircle*/(m, m, w)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} index
|
||||||
|
* @param {Graphics} g
|
||||||
|
* @param {number} cell
|
||||||
|
*/
|
||||||
|
function outerShape(index, g, cell) {
|
||||||
|
index = index % 4;
|
||||||
|
|
||||||
|
var m;
|
||||||
|
|
||||||
|
!index ?
|
||||||
|
g.j/*addTriangle*/(0, 0, cell, cell, 0) :
|
||||||
|
|
||||||
|
index == 1 ?
|
||||||
|
g.j/*addTriangle*/(0, cell / 2, cell, cell / 2, 0) :
|
||||||
|
|
||||||
|
index == 2 ?
|
||||||
|
g.N/*addRhombus*/(0, 0, cell, cell) :
|
||||||
|
|
||||||
|
// 3
|
||||||
|
(
|
||||||
|
m = cell / 6,
|
||||||
|
g.h/*addCircle*/(m, m, cell - 2 * m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a SHA1 hash for any value and returns it as a hexadecimal string.
|
||||||
|
*
|
||||||
|
* This function is optimized for minimal code size and rather short messages.
|
||||||
|
*
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
async function computeHash(message) {
|
||||||
|
const hashArray = Array.from(new Uint8Array(
|
||||||
|
// await crypto.subtle.digest('SHA-256', (new TextEncoder()).encode(message))
|
||||||
|
await crypto.subtle.digest('SHA-1', (new TextEncoder()).encode(message))
|
||||||
|
));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares a measure to be used as a measure in an SVG path, by
|
||||||
|
* rounding the measure to a single decimal. This reduces the file
|
||||||
|
* size of the generated SVG with more than 50% in some cases.
|
||||||
|
*/
|
||||||
|
function svgValue(value) {
|
||||||
|
return ((value * 10 + 0.5) | 0) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an SVG path element.
|
||||||
|
*/
|
||||||
|
class SvgPath
|
||||||
|
{
|
||||||
|
constructor() {
|
||||||
|
/**
|
||||||
|
* This property holds the data string (path.d) of the SVG path.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.B/*dataString*/ = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a polygon with the current fill color to the SVG path.
|
||||||
|
* @param points An array of Point objects.
|
||||||
|
*/
|
||||||
|
g/*addPolygon*/(points) {
|
||||||
|
var dataString = "";
|
||||||
|
for (var i = 0; i < points.length; i++) {
|
||||||
|
dataString += (i ? "L" : "M") + svgValue(points[i].x) + " " + svgValue(points[i].y);
|
||||||
|
}
|
||||||
|
this.B/*dataString*/ += dataString + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a circle with the current fill color to the SVG path.
|
||||||
|
* @param {Point} point The upper left corner of the circle bounding box.
|
||||||
|
* @param {number} diameter The diameter of the circle.
|
||||||
|
* @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path).
|
||||||
|
*/
|
||||||
|
h/*addCircle*/(point, diameter, counterClockwise) {
|
||||||
|
var sweepFlag = counterClockwise ? 0 : 1,
|
||||||
|
svgRadius = svgValue(diameter / 2),
|
||||||
|
svgDiameter = svgValue(diameter),
|
||||||
|
svgArc = "a" + svgRadius + "," + svgRadius + " 0 1," + sweepFlag + " ";
|
||||||
|
|
||||||
|
this.B/*dataString*/ +=
|
||||||
|
"M" + svgValue(point.x) + " " + svgValue(point.y + diameter / 2) +
|
||||||
|
svgArc + svgDiameter + ",0" +
|
||||||
|
svgArc + (-svgDiameter) + ",0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer producing SVG output.
|
||||||
|
* @implements {Renderer}
|
||||||
|
*/
|
||||||
|
class SvgRenderer
|
||||||
|
{
|
||||||
|
constructor(target) {
|
||||||
|
/**
|
||||||
|
* @type {SvgPath}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.C/*_path*/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object.<string,SvgPath>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.D/*_pathsByColor*/ = { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {SvgElement|SvgWriter}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.R/*_target*/ = target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.k/*iconSize*/ = target.k/*iconSize*/;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the background with the specified color.
|
||||||
|
* @param {string} fillColor Fill color on the format #rrggbb[aa].
|
||||||
|
*/
|
||||||
|
m/*setBackground*/(fillColor) {
|
||||||
|
var match = /^(#......)(..)?/.exec(fillColor),
|
||||||
|
opacity = match[2] ? parseHex(match[2], 0) / 255 : 1;
|
||||||
|
this.R/*_target*/.m/*setBackground*/(match[1], opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the beginning of a new shape of the specified color. Should be ended with a call to endShape.
|
||||||
|
* @param {string} color Fill color on format #xxxxxx.
|
||||||
|
*/
|
||||||
|
O/*beginShape*/(color) {
|
||||||
|
this.C/*_path*/ = this.D/*_pathsByColor*/[color] || (this.D/*_pathsByColor*/[color] = new SvgPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the end of the currently drawn shape.
|
||||||
|
*/
|
||||||
|
P/*endShape*/() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a polygon with the current fill color to the SVG.
|
||||||
|
* @param points An array of Point objects.
|
||||||
|
*/
|
||||||
|
g/*addPolygon*/(points) {
|
||||||
|
this.C/*_path*/.g/*addPolygon*/(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a circle with the current fill color to the SVG.
|
||||||
|
* @param {Point} point The upper left corner of the circle bounding box.
|
||||||
|
* @param {number} diameter The diameter of the circle.
|
||||||
|
* @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path).
|
||||||
|
*/
|
||||||
|
h/*addCircle*/(point, diameter, counterClockwise) {
|
||||||
|
this.C/*_path*/.h/*addCircle*/(point, diameter, counterClockwise);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the icon has been completely drawn.
|
||||||
|
*/
|
||||||
|
finish() {
|
||||||
|
var pathsByColor = this.D/*_pathsByColor*/;
|
||||||
|
for (var color in pathsByColor) {
|
||||||
|
// hasOwnProperty cannot be shadowed in pathsByColor
|
||||||
|
// eslint-disable-next-line no-prototype-builtins
|
||||||
|
if (pathsByColor.hasOwnProperty(color)) {
|
||||||
|
this.R/*_target*/.S/*appendPath*/(color, pathsByColor[color].B/*dataString*/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer producing SVG output.
|
||||||
|
*/
|
||||||
|
class SvgWriter
|
||||||
|
{
|
||||||
|
constructor(iconSize) {
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.k/*iconSize*/ = iconSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.F =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
|
||||||
|
iconSize + '" height="' + iconSize + '" viewBox="0 0 ' +
|
||||||
|
iconSize + ' ' + iconSize + '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the background with the specified color.
|
||||||
|
* @param {string} fillColor Fill color on the format #rrggbb.
|
||||||
|
* @param {number} opacity Opacity in the range [0.0, 1.0].
|
||||||
|
*/
|
||||||
|
m/*setBackground*/(fillColor, opacity) {
|
||||||
|
if (opacity) {
|
||||||
|
this.F += '<rect width="100%" height="100%" fill="' +
|
||||||
|
fillColor + '" opacity="' + opacity.toFixed(2) + '"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a path to the SVG string.
|
||||||
|
* @param {string} color Fill color on format #rrggbb.
|
||||||
|
* @param {string} dataString The SVG path data string.
|
||||||
|
*/
|
||||||
|
S/*appendPath*/(color, dataString) {
|
||||||
|
this.F += '<path fill="' + color + '" d="' + dataString + '"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the rendered image as an SVG string.
|
||||||
|
*/
|
||||||
|
toString() {
|
||||||
|
return this.F + "</svg>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws an identicon as an SVG string.
|
||||||
|
* @param {*} hashOrValue - A hexadecimal hash string or any value that will be hashed by Jdenticon.
|
||||||
|
* @returns {string} SVG string
|
||||||
|
*/
|
||||||
|
window.identiconSvg = async (hashOrValue) => {
|
||||||
|
const writer = new SvgWriter(50),
|
||||||
|
renderer = new SvgRenderer(writer),
|
||||||
|
hash = (/^[0-9a-f]{11,}$/i.test(hashOrValue) && hashOrValue)
|
||||||
|
|| await computeHash(hashOrValue == null ? "" : "" + hashOrValue),
|
||||||
|
config = {
|
||||||
|
p/*colorSaturation*/: 0.5,
|
||||||
|
H/*grayscaleSaturation*/: 0,
|
||||||
|
q/*colorLightness*/: value => between(0, 1, 0.4 + value * (0.8 - 0.4)),
|
||||||
|
I/*grayscaleLightness*/: value => between(0, 1, 0.3 + value * (0.9 - 0.3))
|
||||||
|
};
|
||||||
|
|
||||||
|
var index;
|
||||||
|
|
||||||
|
// Calculate padding and round to nearest integer
|
||||||
|
var size = renderer.k/*iconSize*/;
|
||||||
|
var padding = (0.5 + size * 0.08) | 0;
|
||||||
|
size -= padding * 2;
|
||||||
|
|
||||||
|
const graphics = new Graphics(renderer),
|
||||||
|
|
||||||
|
// Calculate cell size and ensure it is an integer
|
||||||
|
cell = 0 | (size / 4),
|
||||||
|
|
||||||
|
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
|
||||||
|
s = 0 | (padding + size / 2 - cell * 2),
|
||||||
|
|
||||||
|
// AVAILABLE COLORS
|
||||||
|
hue = parseHex(hash, -7) / 0xfffffff,
|
||||||
|
|
||||||
|
// Available colors for this icon
|
||||||
|
availableColors = [
|
||||||
|
// Dark gray
|
||||||
|
correctedHsl(hue, config.H/*grayscaleSaturation*/, config.I/*grayscaleLightness*/(0)),
|
||||||
|
// Mid color
|
||||||
|
correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(0.5)),
|
||||||
|
// Light gray
|
||||||
|
correctedHsl(hue, config.H/*grayscaleSaturation*/, config.I/*grayscaleLightness*/(1)),
|
||||||
|
// Light color
|
||||||
|
correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(1)),
|
||||||
|
// Dark color
|
||||||
|
correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(0))
|
||||||
|
],
|
||||||
|
|
||||||
|
// The index of the selected colors
|
||||||
|
selectedColorIndexes = [],
|
||||||
|
|
||||||
|
isDuplicate = values => {
|
||||||
|
if (values.indexOf(index) >= 0) {
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
if (selectedColorIndexes.indexOf(values[i]) >= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderShape = (colorIndex, shapes, index, rotationIndex, positions) => {
|
||||||
|
var shapeIndex = parseHex(hash, index, 1);
|
||||||
|
var r = rotationIndex ? parseHex(hash, rotationIndex, 1) : 0;
|
||||||
|
|
||||||
|
renderer.O/*beginShape*/(availableColors[selectedColorIndexes[colorIndex]]);
|
||||||
|
|
||||||
|
for (var i = 0; i < positions.length; i++) {
|
||||||
|
graphics.A/*currentTransform*/
|
||||||
|
= new Transform(s + positions[i][0] * cell, s + positions[i][1] * cell, cell, r++ % 4);
|
||||||
|
shapes(shapeIndex, graphics, cell, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.P/*endShape*/();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
index = parseHex(hash, 8 + i, 1) % availableColors.length;
|
||||||
|
if (isDuplicate([0, 4]) || // Disallow dark gray and dark color combo
|
||||||
|
isDuplicate([2, 3])) { // Disallow light gray and light color combo
|
||||||
|
index = 1;
|
||||||
|
}
|
||||||
|
selectedColorIndexes.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACTUAL RENDERING
|
||||||
|
// Sides
|
||||||
|
renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
|
||||||
|
// Corners
|
||||||
|
renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
|
||||||
|
// Center
|
||||||
|
renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
|
||||||
|
|
||||||
|
renderer.finish();
|
||||||
|
|
||||||
|
return 'data:image/svg+xml;base64,' + btoa(writer);
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace RainLoop\Actions;
|
||||||
|
|
||||||
|
use RainLoop\Enumerations\Capa;
|
||||||
|
use RainLoop\Utils;
|
||||||
|
|
||||||
|
trait Attachments
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws \MailSo\RuntimeException
|
||||||
|
*/
|
||||||
|
public function DoAttachmentsActions() : array
|
||||||
|
{
|
||||||
|
$sAction = $this->GetActionParam('Do', '');
|
||||||
|
$sFolder = $this->GetActionParam('Folder', '');
|
||||||
|
$aHashes = $this->GetActionParam('Hashes', null);
|
||||||
|
$oFilesProvider = $this->FilesProvider();
|
||||||
|
if (empty($sAction) || !$this->GetCapa(Capa::ATTACHMENTS_ACTIONS) || !$oFilesProvider || !$oFilesProvider->IsActive()) {
|
||||||
|
return $this->FalseResponse(__FUNCTION__);
|
||||||
|
}
|
||||||
|
|
||||||
|
$oAccount = $this->initMailClientConnection();
|
||||||
|
|
||||||
|
$bError = false;
|
||||||
|
$aData = [];
|
||||||
|
$mUIDs = [];
|
||||||
|
|
||||||
|
if (\is_array($aHashes) && \count($aHashes)) {
|
||||||
|
foreach ($aHashes as $sZipHash) {
|
||||||
|
$aResult = $this->getMimeFileByHash($oAccount, $sZipHash);
|
||||||
|
if (empty($aResult['FileHash'])) {
|
||||||
|
$bError = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$aData[] = $aResult;
|
||||||
|
if (!empty($aResult['MimeIndex'])) {
|
||||||
|
$mUIDs[$aResult['Uid']] = $aResult['Uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mUIDs = 1 < \count($mUIDs);
|
||||||
|
|
||||||
|
if ($bError || !\count($aData)) {
|
||||||
|
return $this->FalseResponse(__FUNCTION__);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mResult = false;
|
||||||
|
switch (\strtolower($sAction))
|
||||||
|
{
|
||||||
|
case 'zip':
|
||||||
|
|
||||||
|
$sZipHash = \MailSo\Base\Utils::Sha1Rand();
|
||||||
|
$sZipFileName = $oFilesProvider->GenerateLocalFullFileName($oAccount, $sZipHash);
|
||||||
|
|
||||||
|
if (!empty($sZipFileName)) {
|
||||||
|
if (\class_exists('ZipArchive')) {
|
||||||
|
$oZip = new \ZipArchive();
|
||||||
|
$oZip->open($sZipFileName, \ZIPARCHIVE::CREATE | \ZIPARCHIVE::OVERWRITE);
|
||||||
|
$oZip->setArchiveComment('SnappyMail/'.APP_VERSION);
|
||||||
|
foreach ($aData as $aItem) {
|
||||||
|
$sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']);
|
||||||
|
$sFileName = ($mUIDs ? "{$aItem['Uid']}/" : ($sFolder ? "{$aItem['Uid']}-" : '')) . $aItem['FileName'];
|
||||||
|
if (!$oZip->addFile($sFullFileNameHash, $sFileName)) {
|
||||||
|
$bError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bError) {
|
||||||
|
$oZip->close();
|
||||||
|
} else {
|
||||||
|
$bError = !$oZip->close();
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
} else {
|
||||||
|
@\unlink($sZipFileName);
|
||||||
|
$oZip = new \SnappyMail\Stream\ZIP($sZipFileName);
|
||||||
|
// $oZip->setArchiveComment('SnappyMail/'.APP_VERSION);
|
||||||
|
foreach ($aData as $aItem) {
|
||||||
|
if ($aItem['FileHash']) {
|
||||||
|
$sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']);
|
||||||
|
if (!$oZip->addFile($sFullFileNameHash, $aItem['FileName'])) {
|
||||||
|
$bError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$oZip->close();
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
@\unlink($sZipFileName);
|
||||||
|
$oZip = new \PharData($sZipFileName . '.zip', 0, null, \Phar::ZIP);
|
||||||
|
$oZip->compressFiles(\Phar::GZ);
|
||||||
|
foreach ($aData as $aItem) {
|
||||||
|
$oZip->addFile(
|
||||||
|
$oFilesProvider->GetFileName($oAccount, $aItem['FileHash']),
|
||||||
|
($mUIDs ? "{$aItem['Uid']}/" : ($sFolder ? "{$aItem['Uid']}-" : '')) . $aItem['FileName']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$oZip->compressFiles(\Phar::GZ);
|
||||||
|
unset($oZip);
|
||||||
|
\rename($sZipFileName . '.zip', $sZipFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($aData as $aItem) {
|
||||||
|
$oFilesProvider->Clear($oAccount, $aItem['FileHash']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$bError) {
|
||||||
|
$mResult = array(
|
||||||
|
'FileHash' => Utils::EncodeKeyValuesQ(array(
|
||||||
|
'Account' => $oAccount ? $oAccount->Hash() : '',
|
||||||
|
'FileName' => ($sFolder ? 'messages' : 'attachments') . \date('-YmdHis') . '.zip',
|
||||||
|
'MimeType' => 'application/zip',
|
||||||
|
'FileHash' => $sZipHash
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$data = new \SnappyMail\AttachmentsAction;
|
||||||
|
$data->action = $sAction;
|
||||||
|
$data->items = $aData;
|
||||||
|
$data->filesProvider = $oFilesProvider;
|
||||||
|
$data->account = $oAccount;
|
||||||
|
$this->Plugins()->RunHook('json.attachments', array($data));
|
||||||
|
$mResult = $data->result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $this->requestSleep();
|
||||||
|
return $this->DefaultResponse(__FUNCTION__, $bError ? false : $mResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMimeFileByHash(\RainLoop\Model\Account $oAccount, string $sHash) : array
|
||||||
|
{
|
||||||
|
$aValues = $this->getDecodedRawKeyValue($sHash);
|
||||||
|
|
||||||
|
$sFolder = isset($aValues['Folder']) ? (string) $aValues['Folder'] : '';
|
||||||
|
$iUid = isset($aValues['Uid']) ? (int) $aValues['Uid'] : 0;
|
||||||
|
$sMimeIndex = isset($aValues['MimeIndex']) ? (string) $aValues['MimeIndex'] : '';
|
||||||
|
|
||||||
|
$sContentTypeIn = isset($aValues['MimeType']) ? (string) $aValues['MimeType'] : '';
|
||||||
|
$sFileNameIn = isset($aValues['FileName']) ? (string) $aValues['FileName'] : 'file.dat';
|
||||||
|
|
||||||
|
$oFileProvider = $this->FilesProvider();
|
||||||
|
|
||||||
|
$sResultHash = '';
|
||||||
|
|
||||||
|
$mResult = $this->MailClient()->MessageMimeStream(function ($rResource, $sContentType, $sFileName, $sMimeIndex = '')
|
||||||
|
use ($oAccount, $oFileProvider, $sFileNameIn, $sContentTypeIn, &$sResultHash) {
|
||||||
|
|
||||||
|
unset($sContentType, $sFileName, $sMimeIndex);
|
||||||
|
|
||||||
|
if (\is_resource($rResource))
|
||||||
|
{
|
||||||
|
$sHash = \MailSo\Base\Utils::Sha1Rand($sFileNameIn.'~'.$sContentTypeIn);
|
||||||
|
$rTempResource = $oFileProvider->GetFile($oAccount, $sHash, 'wb+');
|
||||||
|
|
||||||
|
if (\is_resource($rTempResource))
|
||||||
|
{
|
||||||
|
if (false !== \MailSo\Base\Utils::MultipleStreamWriter($rResource, array($rTempResource)))
|
||||||
|
{
|
||||||
|
$sResultHash = $sHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
\fclose($rTempResource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, $sFolder, $iUid, $sMimeIndex);
|
||||||
|
|
||||||
|
$aValues['FileName'] = $sFileNameIn;
|
||||||
|
$aValues['FileHash'] = $mResult ? $sResultHash : '';
|
||||||
|
|
||||||
|
return $aValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ trait Themes
|
||||||
return \in_array($sTheme, $this->GetThemes()) ? $sTheme : $this->Config()->Get('themes', 'default', 'Default');
|
return \in_array($sTheme, $this->GetThemes()) ? $sTheme : $this->Config()->Get('themes', 'default', 'Default');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function compileCss(string $sTheme, bool $bAdmin) : string
|
public function compileCss(string $sTheme, bool $bAdmin, bool $bMinified = false) : string
|
||||||
{
|
{
|
||||||
$bCustomTheme = '@custom' === \substr($sTheme, -7);
|
$bCustomTheme = '@custom' === \substr($sTheme, -7);
|
||||||
if ($bCustomTheme) {
|
if ($bCustomTheme) {
|
||||||
|
|
@ -118,7 +118,7 @@ trait Themes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$mResult[] = $this->Plugins()->CompileCss($bAdmin, $bLess);
|
$mResult[] = $this->Plugins()->CompileCss($bAdmin, $bLess, $bMinified);
|
||||||
|
|
||||||
$mResult = \preg_replace('@(url\(["\']?)(\\./)?([a-z]+[^:a-z])@',
|
$mResult = \preg_replace('@(url\(["\']?)(\\./)?([a-z]+[^:a-z])@',
|
||||||
"\$1{$sBase}\$3",
|
"\$1{$sBase}\$3",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ trait User
|
||||||
use Filters;
|
use Filters;
|
||||||
use Folders;
|
use Folders;
|
||||||
use Messages;
|
use Messages;
|
||||||
|
use Attachments;
|
||||||
use Pgp;
|
use Pgp;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -68,131 +69,6 @@ trait User
|
||||||
return $this->DefaultResponse(__FUNCTION__, $this->AppData(false));
|
return $this->DefaultResponse(__FUNCTION__, $this->AppData(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws \MailSo\RuntimeException
|
|
||||||
*/
|
|
||||||
public function DoAttachmentsActions() : array
|
|
||||||
{
|
|
||||||
$sAction = $this->GetActionParam('Do', '');
|
|
||||||
$aHashes = $this->GetActionParam('Hashes', null);
|
|
||||||
$oFilesProvider = $this->FilesProvider();
|
|
||||||
if (empty($sAction) || !$this->GetCapa(Capa::ATTACHMENTS_ACTIONS) || !$oFilesProvider || !$oFilesProvider->IsActive()) {
|
|
||||||
return $this->FalseResponse(__FUNCTION__);
|
|
||||||
}
|
|
||||||
|
|
||||||
$oAccount = $this->initMailClientConnection();
|
|
||||||
|
|
||||||
$bError = false;
|
|
||||||
$aData = [];
|
|
||||||
$mUIDs = [];
|
|
||||||
|
|
||||||
if (\is_array($aHashes) && \count($aHashes)) {
|
|
||||||
foreach ($aHashes as $sZipHash) {
|
|
||||||
$aResult = $this->getMimeFileByHash($oAccount, $sZipHash);
|
|
||||||
if (empty($aResult['FileHash'])) {
|
|
||||||
$bError = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$aData[] = $aResult;
|
|
||||||
$mUIDs[$aResult['Uid']] = $aResult['Uid'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$mUIDs = 1 < \count($mUIDs);
|
|
||||||
|
|
||||||
if ($bError || !\count($aData)) {
|
|
||||||
return $this->FalseResponse(__FUNCTION__);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mResult = false;
|
|
||||||
switch (\strtolower($sAction))
|
|
||||||
{
|
|
||||||
case 'zip':
|
|
||||||
|
|
||||||
$sZipHash = \MailSo\Base\Utils::Sha1Rand();
|
|
||||||
$sZipFileName = $oFilesProvider->GenerateLocalFullFileName($oAccount, $sZipHash);
|
|
||||||
|
|
||||||
if (!empty($sZipFileName)) {
|
|
||||||
if (\class_exists('ZipArchive')) {
|
|
||||||
$oZip = new \ZipArchive();
|
|
||||||
$oZip->open($sZipFileName, \ZIPARCHIVE::CREATE | \ZIPARCHIVE::OVERWRITE);
|
|
||||||
$oZip->setArchiveComment('SnappyMail/'.APP_VERSION);
|
|
||||||
foreach ($aData as $aItem) {
|
|
||||||
$sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']);
|
|
||||||
$sFileName = ($mUIDs ? "{$aItem['Uid']}/" : '') . ($aItem['FileName'] ?: 'file.dat');
|
|
||||||
if (!$oZip->addFile($sFullFileNameHash, $sFileName)) {
|
|
||||||
$bError = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($bError) {
|
|
||||||
$oZip->close();
|
|
||||||
} else {
|
|
||||||
$bError = !$oZip->close();
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
} else {
|
|
||||||
@\unlink($sZipFileName);
|
|
||||||
$oZip = new \SnappyMail\Stream\ZIP($sZipFileName);
|
|
||||||
// $oZip->setArchiveComment('SnappyMail/'.APP_VERSION);
|
|
||||||
foreach ($aData as $aItem) {
|
|
||||||
$sFileName = (string) (isset($aItem['FileName']) ? $aItem['FileName'] : 'file.dat');
|
|
||||||
$sFileHash = (string) (isset($aItem['FileHash']) ? $aItem['FileHash'] : '');
|
|
||||||
if (!empty($sFileHash)) {
|
|
||||||
$sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $sFileHash);
|
|
||||||
if (!$oZip->addFile($sFullFileNameHash, $sFileName)) {
|
|
||||||
$bError = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$oZip->close();
|
|
||||||
*/
|
|
||||||
} else {
|
|
||||||
@\unlink($sZipFileName);
|
|
||||||
$oZip = new \PharData($sZipFileName . '.zip', 0, null, \Phar::ZIP);
|
|
||||||
$oZip->compressFiles(\Phar::GZ);
|
|
||||||
foreach ($aData as $aItem) {
|
|
||||||
$oZip->addFile(
|
|
||||||
$oFilesProvider->GetFileName($oAccount, $aItem['FileHash']),
|
|
||||||
($mUIDs ? "{$aItem['Uid']}/" : '') . ($aItem['FileName'] ?: 'file.dat')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$oZip->compressFiles(\Phar::GZ);
|
|
||||||
unset($oZip);
|
|
||||||
\rename($sZipFileName . '.zip', $sZipFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($aData as $aItem) {
|
|
||||||
$oFilesProvider->Clear($oAccount, $aItem['FileHash']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$bError) {
|
|
||||||
$mResult = array(
|
|
||||||
'FileHash' => Utils::EncodeKeyValuesQ(array(
|
|
||||||
'Account' => $oAccount ? $oAccount->Hash() : '',
|
|
||||||
'FileName' => 'attachments.zip',
|
|
||||||
'MimeType' => 'application/zip',
|
|
||||||
'FileHash' => $sZipHash
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
$data = new \SnappyMail\AttachmentsAction;
|
|
||||||
$data->action = $sAction;
|
|
||||||
$data->items = $aData;
|
|
||||||
$data->filesProvider = $oFilesProvider;
|
|
||||||
$data->account = $oAccount;
|
|
||||||
$this->Plugins()->RunHook('json.attachments', array($data));
|
|
||||||
$mResult = $data->result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// $this->requestSleep();
|
|
||||||
return $this->DefaultResponse(__FUNCTION__, $bError ? false : $mResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function DoLogout() : array
|
public function DoLogout() : array
|
||||||
{
|
{
|
||||||
$bMain = true; // empty($_COOKIE[self::AUTH_ADDITIONAL_TOKEN_KEY]);
|
$bMain = true; // empty($_COOKIE[self::AUTH_ADDITIONAL_TOKEN_KEY]);
|
||||||
|
|
@ -468,53 +344,6 @@ trait User
|
||||||
$this->SettingsProvider()->Save($oAccount, $oSettings) : false);
|
$this->SettingsProvider()->Save($oAccount, $oSettings) : false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getMimeFileByHash(\RainLoop\Model\Account $oAccount, string $sHash) : array
|
|
||||||
{
|
|
||||||
$aValues = $this->getDecodedRawKeyValue($sHash);
|
|
||||||
|
|
||||||
$sFolder = isset($aValues['Folder']) ? (string) $aValues['Folder'] : '';
|
|
||||||
$iUid = isset($aValues['Uid']) ? (int) $aValues['Uid'] : 0;
|
|
||||||
$sMimeIndex = isset($aValues['MimeIndex']) ? (string) $aValues['MimeIndex'] : '';
|
|
||||||
|
|
||||||
$sContentTypeIn = isset($aValues['MimeType']) ? (string) $aValues['MimeType'] : '';
|
|
||||||
$sFileNameIn = isset($aValues['FileName']) ? (string) $aValues['FileName'] : '';
|
|
||||||
|
|
||||||
$oFileProvider = $this->FilesProvider();
|
|
||||||
|
|
||||||
$sResultHash = '';
|
|
||||||
|
|
||||||
$mResult = $this->MailClient()->MessageMimeStream(function ($rResource, $sContentType, $sFileName, $sMimeIndex = '')
|
|
||||||
use ($oAccount, $oFileProvider, $sFileNameIn, $sContentTypeIn, &$sResultHash) {
|
|
||||||
|
|
||||||
unset($sContentType, $sFileName, $sMimeIndex);
|
|
||||||
|
|
||||||
if ($oAccount && \is_resource($rResource))
|
|
||||||
{
|
|
||||||
$sHash = \MailSo\Base\Utils::Sha1Rand($sFileNameIn.'~'.$sContentTypeIn);
|
|
||||||
$rTempResource = $oFileProvider->GetFile($oAccount, $sHash, 'wb+');
|
|
||||||
|
|
||||||
if (\is_resource($rTempResource))
|
|
||||||
{
|
|
||||||
if (false !== \MailSo\Base\Utils::MultipleStreamWriter($rResource, array($rTempResource)))
|
|
||||||
{
|
|
||||||
$sResultHash = $sHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
\fclose($rTempResource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}, $sFolder, $iUid, $sMimeIndex);
|
|
||||||
|
|
||||||
$aValues['FileHash'] = '';
|
|
||||||
if ($mResult)
|
|
||||||
{
|
|
||||||
$aValues['FileHash'] = $sResultHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $aValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function setSettingsFromParams(\RainLoop\Settings $oSettings, string $sConfigName, string $sType = 'string', ?callable $cCallback = null) : void
|
private function setSettingsFromParams(\RainLoop\Settings $oSettings, string $sConfigName, string $sType = 'string', ?callable $cCallback = null) : void
|
||||||
{
|
{
|
||||||
if ($this->HasActionParam($sConfigName))
|
if ($this->HasActionParam($sConfigName))
|
||||||
|
|
|
||||||
|
|
@ -334,11 +334,9 @@ trait UserAuth
|
||||||
|
|
||||||
private function SetSignMeToken(MainAccount $oAccount): void
|
private function SetSignMeToken(MainAccount $oAccount): void
|
||||||
{
|
{
|
||||||
$this->ClearSignMeData();
|
// $this->ClearSignMeData();
|
||||||
|
|
||||||
$uuid = \SnappyMail\UUID::generate();
|
$uuid = \SnappyMail\UUID::generate();
|
||||||
$data = \SnappyMail\Crypt::Encrypt($oAccount);
|
$data = \SnappyMail\Crypt::Encrypt($oAccount);
|
||||||
|
|
||||||
Utils::SetCookie(
|
Utils::SetCookie(
|
||||||
self::AUTH_SIGN_ME_TOKEN_KEY,
|
self::AUTH_SIGN_ME_TOKEN_KEY,
|
||||||
\SnappyMail\Crypt::EncryptUrlSafe([
|
\SnappyMail\Crypt::EncryptUrlSafe([
|
||||||
|
|
@ -348,13 +346,7 @@ trait UserAuth
|
||||||
]),
|
]),
|
||||||
\time() + 3600 * 24 * 30 // 30 days
|
\time() + 3600 * 24 * 30 // 30 days
|
||||||
);
|
);
|
||||||
|
$this->StorageProvider()->Put($oAccount, StorageType::SIGN_ME, $uuid, $data[2]);
|
||||||
$this->StorageProvider()->Put(
|
|
||||||
$oAccount,
|
|
||||||
StorageType::SIGN_ME,
|
|
||||||
$uuid,
|
|
||||||
$data[2]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GetAccountFromSignMeToken(): ?MainAccount
|
public function GetAccountFromSignMeToken(): ?MainAccount
|
||||||
|
|
@ -368,36 +360,32 @@ trait UserAuth
|
||||||
StorageType::SIGN_ME,
|
StorageType::SIGN_ME,
|
||||||
$aTokenData['u']
|
$aTokenData['u']
|
||||||
);
|
);
|
||||||
if ($sAuthToken) {
|
if (!$sAuthToken) {
|
||||||
$aAccountHash = \SnappyMail\Crypt::Decrypt([
|
throw new \RuntimeException("server token not found for {$aTokenData['e']}/.sign_me/{$aTokenData['u']}");
|
||||||
\array_key_last($aTokenData),
|
|
||||||
\base64_decode(\end($aTokenData)),
|
|
||||||
$sAuthToken
|
|
||||||
]);
|
|
||||||
if (\is_array($aAccountHash)) {
|
|
||||||
$oAccount = MainAccount::NewInstanceFromTokenArray($this, $aAccountHash);
|
|
||||||
if ($oAccount) {
|
|
||||||
$this->CheckMailConnection($oAccount);
|
|
||||||
// Update lifetime
|
|
||||||
$this->SetSignMeToken($oAccount);
|
|
||||||
return $oAccount;
|
|
||||||
}
|
|
||||||
\SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, 'has no account');
|
|
||||||
} else {
|
|
||||||
\SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, 'decrypt failed');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
\SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, "server token not found for {$aTokenData['e']}/.sign_me/{$aTokenData['u']}");
|
|
||||||
}
|
}
|
||||||
|
$aAccountHash = \SnappyMail\Crypt::Decrypt([
|
||||||
|
\array_key_last($aTokenData),
|
||||||
|
\base64_decode(\end($aTokenData)),
|
||||||
|
$sAuthToken
|
||||||
|
]);
|
||||||
|
if (!\is_array($aAccountHash)) {
|
||||||
|
throw new \RuntimeException('token decrypt failed');
|
||||||
|
}
|
||||||
|
$oAccount = MainAccount::NewInstanceFromTokenArray($this, $aAccountHash);
|
||||||
|
if (!$oAccount) {
|
||||||
|
throw new \RuntimeException('token has no account');
|
||||||
|
}
|
||||||
|
$this->CheckMailConnection($oAccount);
|
||||||
|
// Update lifetime
|
||||||
|
$this->SetSignMeToken($oAccount);
|
||||||
|
return $oAccount;
|
||||||
}
|
}
|
||||||
catch (\Throwable $oException)
|
catch (\Throwable $oException)
|
||||||
{
|
{
|
||||||
\SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, $oException->getMessage());
|
\SnappyMail\Log::warning(self::AUTH_SIGN_ME_TOKEN_KEY, $oException->getMessage());
|
||||||
}
|
}
|
||||||
|
$this->ClearSignMeData();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->ClearSignMeData();
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,13 @@ class Manager
|
||||||
|
|
||||||
public function Hash() : string
|
public function Hash() : string
|
||||||
{
|
{
|
||||||
return \md5(\array_reduce($this->aPlugins, function($sResult, $oPlugin){
|
return \md5(
|
||||||
return $sResult . "|{$oPlugin->Hash()}";
|
\array_reduce($this->aPlugins, function($sResult, $oPlugin){
|
||||||
}, APP_VERSION));
|
return $sResult . "|{$oPlugin->Hash()}";
|
||||||
|
}, APP_VERSION)
|
||||||
|
.implode('',$this->aJs[1]).implode('',$this->aJs[0])
|
||||||
|
.implode('',$this->aCss[1]).implode('',$this->aCss[0])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function HaveJs(bool $bAdminScope = false) : bool
|
public function HaveJs(bool $bAdminScope = false) : bool
|
||||||
|
|
@ -179,11 +183,17 @@ class Manager
|
||||||
return $this->bIsEnabled && \count($this->aJs[$bAdminScope ? 1 : 0]);
|
return $this->bIsEnabled && \count($this->aJs[$bAdminScope ? 1 : 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function CompileCss(bool $bAdminScope, bool &$bLess) : string
|
public function CompileCss(bool $bAdminScope, bool &$bLess, bool $bMinified) : string
|
||||||
{
|
{
|
||||||
$aResult = array();
|
$aResult = array();
|
||||||
if ($this->bIsEnabled) {
|
if ($this->bIsEnabled) {
|
||||||
foreach ($this->aCss[$bAdminScope ? 1 : 0] as $sFile) {
|
foreach ($this->aCss[$bAdminScope ? 1 : 0] as $sFile) {
|
||||||
|
if ($bMinified) {
|
||||||
|
$sMinFile = \str_replace('.css', '.min.css', $sFile);
|
||||||
|
if (\is_readable($sMinFile)) {
|
||||||
|
$sFile = $sMinFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (\is_readable($sFile)) {
|
if (\is_readable($sFile)) {
|
||||||
$aResult[] = \file_get_contents($sFile);
|
$aResult[] = \file_get_contents($sFile);
|
||||||
$bLess = $bLess || \str_ends_with($sFile, '.less');
|
$bLess = $bLess || \str_ends_with($sFile, '.less');
|
||||||
|
|
@ -193,15 +203,18 @@ class Manager
|
||||||
return \implode("\n", $aResult);
|
return \implode("\n", $aResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function CompileJs(bool $bAdminScope = false) : string
|
public function CompileJs(bool $bAdminScope = false, bool $bMinified = false) : string
|
||||||
{
|
{
|
||||||
$aResult = array();
|
$aResult = array();
|
||||||
if ($this->bIsEnabled)
|
if ($this->bIsEnabled) {
|
||||||
{
|
foreach ($this->aJs[$bAdminScope ? 1 : 0] as $sFile) {
|
||||||
foreach ($this->aJs[$bAdminScope ? 1 : 0] as $sFile)
|
if ($bMinified) {
|
||||||
{
|
$sMinFile = \str_replace('.js', '.min.js', $sFile);
|
||||||
if (\is_readable($sFile))
|
if (\is_readable($sMinFile)) {
|
||||||
{
|
$sFile = $sMinFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (\is_readable($sFile)) {
|
||||||
$aResult[] = \file_get_contents($sFile);
|
$aResult[] = \file_get_contents($sFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -506,31 +506,29 @@ class ServiceActions
|
||||||
|
|
||||||
\header('Content-Type: application/javascript; charset=utf-8');
|
\header('Content-Type: application/javascript; charset=utf-8');
|
||||||
|
|
||||||
$bCacheEnabled = $this->Config()->Get('labs', 'cache_system_data', true);
|
$bAppDebug = $this->Config()->Get('debug', 'enable', false);
|
||||||
if ($bCacheEnabled)
|
$sMinify = ($bAppDebug || $this->Config()->Get('labs', 'use_app_debug_js', false)) ? '' : 'min';
|
||||||
{
|
|
||||||
$this->oActions->verifyCacheByKey($this->sQuery);
|
$bCacheEnabled = !$bAppDebug && $this->Config()->Get('labs', 'cache_system_data', true);
|
||||||
|
if ($bCacheEnabled) {
|
||||||
|
$this->oActions->verifyCacheByKey($this->sQuery . $sMinify);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sCacheFileName = '';
|
$sCacheFileName = '';
|
||||||
if ($bCacheEnabled)
|
if ($bCacheEnabled) {
|
||||||
{
|
$sCacheFileName = KeyPathHelper::PluginsJsCache($this->oActions->Plugins()->Hash()) . $sMinify;
|
||||||
$sCacheFileName = KeyPathHelper::PluginsJsCache($this->oActions->Plugins()->Hash());
|
|
||||||
$sResult = $this->Cacher()->Get($sCacheFileName);
|
$sResult = $this->Cacher()->Get($sCacheFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!\strlen($sResult))
|
if (!$sResult) {
|
||||||
{
|
$sResult = $this->Plugins()->CompileJs($bAdmin, !!$sMinify);
|
||||||
$sResult = $this->Plugins()->CompileJs($bAdmin);
|
if ($sCacheFileName) {
|
||||||
if ($bCacheEnabled && \strlen($sCacheFileName))
|
|
||||||
{
|
|
||||||
$this->Cacher()->Set($sCacheFileName, $sResult);
|
$this->Cacher()->Set($sCacheFileName, $sResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($bCacheEnabled)
|
if ($bCacheEnabled) {
|
||||||
{
|
$this->oActions->cacheByKey($this->sQuery . $sMinify);
|
||||||
$this->oActions->cacheByKey($this->sQuery);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sResult;
|
return $sResult;
|
||||||
|
|
@ -539,45 +537,38 @@ class ServiceActions
|
||||||
public function ServiceCss() : string
|
public function ServiceCss() : string
|
||||||
{
|
{
|
||||||
$sResult = '';
|
$sResult = '';
|
||||||
|
|
||||||
$bAdmin = !empty($this->aPaths[2]) && 'Admin' === $this->aPaths[2];
|
$bAdmin = !empty($this->aPaths[2]) && 'Admin' === $this->aPaths[2];
|
||||||
$bJson = !empty($this->aPaths[9]) && 'Json' === $this->aPaths[9];
|
$bJson = !empty($this->aPaths[9]) && 'Json' === $this->aPaths[9];
|
||||||
|
|
||||||
if ($bJson)
|
if ($bJson) {
|
||||||
{
|
|
||||||
\header('Content-Type: application/json; charset=utf-8');
|
\header('Content-Type: application/json; charset=utf-8');
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
\header('Content-Type: text/css; charset=utf-8');
|
\header('Content-Type: text/css; charset=utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sTheme = '';
|
$sTheme = '';
|
||||||
if (!empty($this->aPaths[4]))
|
if (!empty($this->aPaths[4])) {
|
||||||
{
|
|
||||||
$sTheme = $this->oActions->ValidateTheme($this->aPaths[4]);
|
$sTheme = $this->oActions->ValidateTheme($this->aPaths[4]);
|
||||||
|
|
||||||
$bCacheEnabled = $this->Config()->Get('labs', 'cache_system_data', true);
|
$bAppDebug = $this->Config()->Get('debug', 'enable', false);
|
||||||
if ($bCacheEnabled)
|
$sMinify = ($bAppDebug || $this->Config()->Get('labs', 'use_app_debug_css', false)) ? '' : 'min';
|
||||||
{
|
|
||||||
$this->oActions->verifyCacheByKey($this->sQuery);
|
$bCacheEnabled = !$bAppDebug && $this->Config()->Get('labs', 'cache_system_data', true);
|
||||||
|
if ($bCacheEnabled) {
|
||||||
|
$this->oActions->verifyCacheByKey($this->sQuery . $sMinify);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sCacheFileName = '';
|
$sCacheFileName = '';
|
||||||
if ($bCacheEnabled)
|
if ($bCacheEnabled) {
|
||||||
{
|
$sCacheFileName = KeyPathHelper::CssCache($sTheme, $this->oActions->Plugins()->Hash()) . $sMinify;
|
||||||
$sCacheFileName = KeyPathHelper::CssCache($sTheme, $this->oActions->Plugins()->Hash());
|
|
||||||
$sResult = $this->Cacher()->Get($sCacheFileName);
|
$sResult = $this->Cacher()->Get($sCacheFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$sResult)
|
if (!$sResult) {
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$sResult = $this->oActions->compileCss($sTheme, $bAdmin);
|
$sResult = $this->oActions->compileCss($sTheme, $bAdmin);
|
||||||
|
if ($sCacheFileName) {
|
||||||
if ($bCacheEnabled && $sCacheFileName)
|
|
||||||
{
|
|
||||||
$this->Cacher()->Set($sCacheFileName, $sResult);
|
$this->Cacher()->Set($sCacheFileName, $sResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -587,9 +578,8 @@ class ServiceActions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($bCacheEnabled)
|
if ($bCacheEnabled) {
|
||||||
{
|
$this->oActions->cacheByKey($this->sQuery . $sMinify);
|
||||||
$this->oActions->cacheByKey($this->sQuery);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,12 +195,17 @@ class Utils
|
||||||
*/
|
*/
|
||||||
// Set the new 4K split cookie
|
// Set the new 4K split cookie
|
||||||
foreach (\str_split($sValue, $iMaxSize) as $i => $sPart) {
|
foreach (\str_split($sValue, $iMaxSize) as $i => $sPart) {
|
||||||
|
\SnappyMail\Log::debug('COOKIE', "set {$sName}~{$i}");
|
||||||
static::_SetCookie($i ? "{$sName}~{$i}" : $sName, $sPart, $iExpire);
|
static::_SetCookie($i ? "{$sName}~{$i}" : $sName, $sPart, $iExpire);
|
||||||
}
|
}
|
||||||
// Delete unused old 4K split cookie parts
|
// Delete unused old 4K split cookie parts
|
||||||
while (($sCookieName = "{$sName}~" . ++$i) && isset($_COOKIE[$sCookieName])) {
|
foreach (\array_keys($_COOKIE) as $sCookieName) {
|
||||||
unset($_COOKIE[$sCookieName]);
|
$aSplit = \explode('~', $sCookieName);
|
||||||
static::_SetCookie($sCookieName, '', \time() - 3600 * 24 * 30);
|
if (isset($aSplit[1]) && $aSplit[0] == $sName && $aSplit[1] > $i) {
|
||||||
|
\SnappyMail\Log::debug('COOKIE', "unset {$sCookieName}");
|
||||||
|
unset($_COOKIE[$sCookieName]);
|
||||||
|
static::_SetCookie($sCookieName, '', \time() - 3600 * 24 * 30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,11 @@ abstract class Crypt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\trigger_error(__CLASS__ . "::{$fn}(): " . $e->getMessage());
|
Log::error('Crypt', "{$fn}(): {$e->getMessage()}");
|
||||||
}
|
}
|
||||||
// \trigger_error(__CLASS__ . '::Decrypt() invalid $data or $key');
|
Log::warning('Crypt', 'Decrypt() invalid $data or $key');
|
||||||
} else {
|
} else {
|
||||||
// \trigger_error(__CLASS__ . '::Decrypt() invalid $data');
|
Log::warning('Crypt', 'Decrypt() invalid $data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ abstract class Crypt
|
||||||
{
|
{
|
||||||
$data = static::jsonDecode($data);
|
$data = static::jsonDecode($data);
|
||||||
if (!\is_array($data)) {
|
if (!\is_array($data)) {
|
||||||
// \trigger_error(__CLASS__ . '::DecryptFromJSON() invalid $data');
|
Log::notice('Crypt', 'DecryptFromJSON() invalid $data');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return static::Decrypt(\array_map('base64_decode', $data), $key);
|
return static::Decrypt(\array_map('base64_decode', $data), $key);
|
||||||
|
|
@ -87,7 +87,7 @@ abstract class Crypt
|
||||||
{
|
{
|
||||||
$data = \explode('.', $data);
|
$data = \explode('.', $data);
|
||||||
if (!\is_array($data)) {
|
if (!\is_array($data)) {
|
||||||
// \trigger_error(__CLASS__ . '::DecryptUrlSafe() invalid $data');
|
Log::notice('Crypt', 'DecryptUrlSafe() invalid $data');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return static::Decrypt(\array_map('MailSo\\Base\\Utils::UrlSafeBase64Decode', $data), $key);
|
return static::Decrypt(\array_map('MailSo\\Base\\Utils::UrlSafeBase64Decode', $data), $key);
|
||||||
|
|
@ -175,6 +175,7 @@ abstract class Crypt
|
||||||
if (!static::$cipher) {
|
if (!static::$cipher) {
|
||||||
throw new \Exception('openssl $cipher not set');
|
throw new \Exception('openssl $cipher not set');
|
||||||
}
|
}
|
||||||
|
Log::debug('Crypt', 'openssl_decrypt() with cipher ' . static::$cipher);
|
||||||
return \openssl_decrypt(
|
return \openssl_decrypt(
|
||||||
$data,
|
$data,
|
||||||
static::$cipher,
|
static::$cipher,
|
||||||
|
|
@ -195,6 +196,7 @@ abstract class Crypt
|
||||||
if (!static::$cipher) {
|
if (!static::$cipher) {
|
||||||
throw new \Exception('openssl $cipher not set');
|
throw new \Exception('openssl $cipher not set');
|
||||||
}
|
}
|
||||||
|
Log::debug('Crypt', 'openssl_encrypt() with cipher ' . static::$cipher);
|
||||||
$result = \openssl_encrypt(
|
$result = \openssl_encrypt(
|
||||||
$data,
|
$data,
|
||||||
static::$cipher,
|
static::$cipher,
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@
|
||||||
"TITLE_SYSTEM_FOLDERS": "Aukeratu sistemaren karpetak",
|
"TITLE_SYSTEM_FOLDERS": "Aukeratu sistemaren karpetak",
|
||||||
"SELECT_CHOOSE_ONE": "Aukeratu bat",
|
"SELECT_CHOOSE_ONE": "Aukeratu bat",
|
||||||
"SELECT_UNUSE_NAME": "Ez erabili",
|
"SELECT_UNUSE_NAME": "Ez erabili",
|
||||||
"NOTIFICATION_SENT": "Ez duzu sistemako \"Bidalitakoak"\ karpeta hautatu mezuak bidali ondoren bertan ipintzeko.\nBidalitako mezua gorde nahi ez baduzu hautatu \"Ez erabili\" aukera.\n",
|
"NOTIFICATION_SENT": "Ez duzu sistemako \"Bidalitakoak\" karpeta hautatu mezuak bidali ondoren bertan ipintzeko.\nBidalitako mezua gorde nahi ez baduzu hautatu \"Ez erabili\" aukera.\n",
|
||||||
"NOTIFICATION_DRAFTS": "Ez duzu sistemako \"Zirriborroak\" karpeta hautatu mezuak editatu bitartean gordetzeko.",
|
"NOTIFICATION_DRAFTS": "Ez duzu sistemako \"Zirriborroak\" karpeta hautatu mezuak editatu bitartean gordetzeko.",
|
||||||
"NOTIFICATION_SPAM": "Ez duzu sistemako \"Zabor-posta\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n",
|
"NOTIFICATION_SPAM": "Ez duzu sistemako \"Zabor-posta\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n",
|
||||||
"NOTIFICATION_TRASH": "Ez duzu sistemako \"Zakarrontzia\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n",
|
"NOTIFICATION_TRASH": "Ez duzu sistemako \"Zakarrontzia\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n",
|
||||||
|
|
@ -509,7 +509,7 @@
|
||||||
"LABEL_SWITCH_TO_LIST": "Itzuli fokua mezuen zerrendara",
|
"LABEL_SWITCH_TO_LIST": "Itzuli fokua mezuen zerrendara",
|
||||||
"LABEL_OPEN_COMPOSE_POPUP": "Ireki prestatzeko popup-a",
|
"LABEL_OPEN_COMPOSE_POPUP": "Ireki prestatzeko popup-a",
|
||||||
"LABEL_MINIMIZE_COMPOSE_POPUP": "Txikitu prestatzeko popup-a",
|
"LABEL_MINIMIZE_COMPOSE_POPUP": "Txikitu prestatzeko popup-a",
|
||||||
"LABEL_OPEN_IDENTITIES_DROPDOWN": "Ireki identitateen goitibeherako menua,
|
"LABEL_OPEN_IDENTITIES_DROPDOWN": "Ireki identitateen goitibeherako menua",
|
||||||
"LABEL_SAVE_MESSAGE": "Gorde mezua",
|
"LABEL_SAVE_MESSAGE": "Gorde mezua",
|
||||||
"LABEL_SEND_MESSAGE": "Bidali mezua",
|
"LABEL_SEND_MESSAGE": "Bidali mezua",
|
||||||
"LABEL_CLOSE_COMPOSE": "Itxi mezua"
|
"LABEL_CLOSE_COMPOSE": "Itxi mezua"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<div class="descWrapper" data-i18n="TOP_PANEL/LABEL_ADMIN_PANEL"></div>
|
<div class="descWrapper" data-i18n="TOP_PANEL/LABEL_ADMIN_PANEL"></div>
|
||||||
<div class="alert" data-bind="hidden: !submitError()">
|
<div class="alert" data-bind="hidden: !submitError()">
|
||||||
<a href="#" class="close" data-bind="click: function () { submitError('') }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: submitError"></span>
|
<span data-bind="text: submitError"></span>
|
||||||
</div>
|
</div>
|
||||||
<form action="#/" spellcheck="false" data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
<form action="#/" spellcheck="false" data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<form class="form-horizontal plugin-form" action="#/" spellcheck="false" onsubmit="return false;">
|
<form class="form-horizontal plugin-form" action="#/" spellcheck="false" onsubmit="return false;">
|
||||||
<div class="alert alert-info" data-bind="visible: !hasConfiguration()" data-i18n="POPUPS_PLUGIN/DESC_NOTHING_TO_CONFIGURE"></div>
|
<div class="alert alert-info" data-bind="visible: !hasConfiguration()" data-i18n="POPUPS_PLUGIN/DESC_NOTHING_TO_CONFIGURE"></div>
|
||||||
<div class="alert" data-bind="visible: '' !== saveError()">
|
<div class="alert" data-bind="visible: '' !== saveError()">
|
||||||
<a href="#" class="close" data-bind="click: function () { saveError('') }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: saveError"></span>
|
<span data-bind="text: saveError"></span>
|
||||||
</div>
|
</div>
|
||||||
<div data-bind="foreach: config, visible: hasConfiguration">
|
<div data-bind="foreach: config, visible: hasConfiguration">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<div class="descWrapper" data-bind="visible: '' !== loadingDesc, text: loadingDesc"></div>
|
<div class="descWrapper" data-bind="visible: '' !== loadingDesc, text: loadingDesc"></div>
|
||||||
<div class="alert" data-bind="hidden: !submitError()" hidden="">
|
<div class="alert" data-bind="hidden: !submitError()" hidden="">
|
||||||
<a href="#" class="close" data-bind="click: function () { submitError('') }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: submitError"></span>
|
<span data-bind="text: submitError"></span>
|
||||||
<p data-bind="visible: '' !== submitErrorAdditional()">
|
<p data-bind="visible: '' !== submitErrorAdditional()">
|
||||||
<span data-i18n="GLOBAL/SERVER_MESSAGE"></span>:
|
<span data-i18n="GLOBAL/SERVER_MESSAGE"></span>:
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="plugin-Login-BottomControlGroup"></div>
|
<div id="plugin-Login-BottomControlGroup"></div>
|
||||||
<div class="controls" style="display: flex">
|
<div class="controls" style="display: flex">
|
||||||
<div class="e-checkbox" tabindex="0" data-bind="click: function(){signMe(!signMe())}, onSpace: function(){signMe(!signMe())}">
|
<div class="e-checkbox" tabindex="0" data-bind="click: toggleSignMe, onSpace: toggleSignMe">
|
||||||
<i role="checkbox" class="fontastic" data-bind="text: signMe() ? '☑' : '☐'"></i>
|
<i role="checkbox" class="fontastic" data-bind="text: signMe() ? '☑' : '☐'"></i>
|
||||||
<span data-i18n="LOGIN/LABEL_SIGN_ME"></span>
|
<span data-i18n="LOGIN/LABEL_SIGN_ME"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,12 @@
|
||||||
<li class="dividerbar" role="presentation" data-bind="command: forwardCommand">
|
<li class="dividerbar" role="presentation" data-bind="command: forwardCommand">
|
||||||
<a href="#" tabindex="-1" data-icon="↞" data-i18n="MESSAGE_LIST/BUTTON_MULTY_FORWARD"></a>
|
<a href="#" tabindex="-1" data-icon="↞" data-i18n="MESSAGE_LIST/BUTTON_MULTY_FORWARD"></a>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" data-bind="command: downloadCommand">
|
<li role="presentation" data-bind="command: downloadAttachCommand, visible: downloadAsZipAllowed">
|
||||||
<a href="#" tabindex="-1" data-icon="" data-i18n="MESSAGE_LIST/DOWNLOAD_ALL_ATTACHMENTS"></a>
|
<a href="#" tabindex="-1" data-icon="" data-i18n="MESSAGE_LIST/DOWNLOAD_ALL_ATTACHMENTS"></a>
|
||||||
</li>
|
</li>
|
||||||
|
<li role="presentation" data-bind="command: downloadZipCommand, visible: downloadAsZipAllowed">
|
||||||
|
<a href="#" tabindex="-1" data-icon="💾" data-i18n="MESSAGE/LINK_DOWNLOAD_AS_ZIP"></a>
|
||||||
|
</li>
|
||||||
<li class="dividerbar" role="presentation" data-bind="command: moveCommand">
|
<li class="dividerbar" role="presentation" data-bind="command: moveCommand">
|
||||||
<a href="#" tabindex="-1" data-icon="📁" data-i18n="GLOBAL/MOVE_TO"></a>
|
<a href="#" tabindex="-1" data-icon="📁" data-i18n="GLOBAL/MOVE_TO"></a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@
|
||||||
|
|
||||||
<div class="messageItemHeader">
|
<div class="messageItemHeader">
|
||||||
<div class="subjectParent">
|
<div class="subjectParent">
|
||||||
<span class="infoParent g-ui-user-select-none fontastic" data-bind="click: function() { showFullInfo(!showFullInfo()); }">ℹ</span>
|
<span class="infoParent g-ui-user-select-none fontastic" data-bind="click: toggleFullInfo">ℹ</span>
|
||||||
<span class="flagParent g-ui-user-select-none flagOff fontastic" data-bind="text: message().isFlagged() ? '★' : '☆', css: {'flagOn': message().isFlagged(), 'flagOff': !message().isFlagged()}"></span>
|
<span class="flagParent g-ui-user-select-none flagOff fontastic" data-bind="text: message().isFlagged() ? '★' : '☆', css: {'flagOn': message().isFlagged(), 'flagOff': !message().isFlagged()}"></span>
|
||||||
<span class="subject" data-bind="text: message().subject, title: message().subject"></span>
|
<span class="subject" data-bind="text: message().subject, title: message().subject"></span>
|
||||||
<a href="#" class="close" data-bind="click: closeMessage" style="margin-top: -8px;">×</a>
|
<a href="#" class="close" data-bind="click: closeMessage" style="margin-top: -8px;">×</a>
|
||||||
|
|
@ -251,8 +251,7 @@
|
||||||
<div class="attachmentSize" data-bind="text: friendlySize()"></div>
|
<div class="attachmentSize" data-bind="text: friendlySize()"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkboxAttachment fontastic"
|
<div class="checkboxAttachment fontastic"
|
||||||
data-bind="visible: download, text: checked() ? '☑' : '☐',
|
data-bind="visible: download, text: checked() ? '☑' : '☐', click: toggleChecked"></div>
|
||||||
click: function () { checked(!checked()); return false }"></div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!-- /ko -->
|
<!-- /ko -->
|
||||||
|
|
@ -261,8 +260,7 @@
|
||||||
<li class="attachmentItem" data-bind="attr: { 'title': fileName }, css: {'checked': checked}">
|
<li class="attachmentItem" data-bind="attr: { 'title': fileName }, css: {'checked': checked}">
|
||||||
<span class="attachmentName" data-bind="text: fileName"></span>
|
<span class="attachmentName" data-bind="text: fileName"></span>
|
||||||
<i class="checkboxAttachment fontastic"
|
<i class="checkboxAttachment fontastic"
|
||||||
data-bind="visible: download, text: checked() ? '☑' : '☐',
|
data-bind="visible: download, text: checked() ? '☑' : '☐', click: toggleChecked"></i>
|
||||||
click: function () { checked(!checked()); return false }"></i>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!-- /ko -->
|
<!-- /ko -->
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
</header>
|
</header>
|
||||||
<form id="accountform" class="modal-body form-horizontal" autocomplete="off" spellcheck="false" data-bind="submit: submitForm">
|
<form id="accountform" class="modal-body form-horizontal" autocomplete="off" spellcheck="false" data-bind="submit: submitForm">
|
||||||
<div class="alert" data-bind="visible: '' !== submitError()">
|
<div class="alert" data-bind="visible: '' !== submitError()">
|
||||||
<a href="#" class="close" data-bind="click: function () { submitError('') }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: submitError"></span>
|
<span data-bind="text: submitError"></span>
|
||||||
<div data-bind="visible: submitErrorAdditional, text: submitErrorAdditional"></div>
|
<div data-bind="visible: submitErrorAdditional, text: submitErrorAdditional"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,281 +43,285 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="b-list-toolbar">
|
<div class="left">
|
||||||
<input type="search" class="e-search" placeholder="Search" autofocus="" autocorrect="off" autocapitalize="off" spellcheck="false" data-i18n="[placeholder]GLOBAL/SEARCH" data-bind="value: search">
|
<div class="b-list-toolbar">
|
||||||
</div>
|
<input type="search" class="e-search" placeholder="Search" autofocus="" autocorrect="off" autocapitalize="off" spellcheck="false" data-i18n="[placeholder]GLOBAL/SEARCH" data-bind="value: search">
|
||||||
<div class="b-list-content g-ui-user-select-none" data-bind="css: {'hideContactListCheckbox': !useCheckboxesInList()}">
|
</div>
|
||||||
<div class="content">
|
<div class="b-list-content g-ui-user-select-none" data-bind="css: {'hideContactListCheckbox': !useCheckboxesInList()}">
|
||||||
<div class="listClear" data-bind="visible: !!search()">
|
<div class="content">
|
||||||
<span class="g-ui-link" data-i18n="CONTACTS/CLEAR_SEARCH" data-bind="click: clearSearch"></span>
|
<div class="listClear" data-bind="visible: !!search()">
|
||||||
</div>
|
<span class="g-ui-link" data-i18n="CONTACTS/CLEAR_SEARCH" data-bind="click: clearSearch"></span>
|
||||||
<div class="listEmptyList" data-bind="visible: 0 === contacts().length && '' === search() && !contacts.loading()"
|
</div>
|
||||||
data-i18n="CONTACTS/EMPTY_LIST"></div>
|
<div class="listEmptyList" data-bind="visible: 0 === contacts().length && '' === search() && !contacts.loading()"
|
||||||
<div class="listEmptyListLoading" data-bind="visible: 0 === contacts().length && '' === search() && contacts.loading()">
|
data-i18n="CONTACTS/EMPTY_LIST"></div>
|
||||||
<i class="icon-spinner"></i>
|
<div class="listEmptyListLoading" data-bind="visible: 0 === contacts().length && '' === search() && contacts.loading()">
|
||||||
<span data-i18n="GLOBAL/LOADING"></span>
|
<i class="icon-spinner"></i>
|
||||||
</div>
|
<span data-i18n="GLOBAL/LOADING"></span>
|
||||||
<div class="listEmptySearchList" data-bind="visible: 0 === contacts().length && '' !== search() && !contacts.loading()"
|
</div>
|
||||||
data-i18n="CONTACTS/EMPTY_SEARCH"></div>
|
<div class="listEmptySearchList" data-bind="visible: 0 === contacts().length && '' !== search() && !contacts.loading()"
|
||||||
<div class="e-contact-foreach g-ui-user-select-none" data-bind="foreach: contacts, visible: contacts().length">
|
data-i18n="CONTACTS/EMPTY_SEARCH"></div>
|
||||||
<div class="e-contact-item g-ui-user-select-none" data-bind="css: lineAsCss()">
|
<div class="e-contact-foreach g-ui-user-select-none" data-bind="foreach: contacts, visible: contacts().length">
|
||||||
<div class="checkboxItem fontastic" data-bind="text: checked() ? '☑' : '☐'"></div>
|
<div class="e-contact-item g-ui-user-select-none" data-bind="css: lineAsCss()">
|
||||||
<div class="nameParent actionHandle" data-bind="text: display"></div>
|
<div class="checkboxItem fontastic" data-bind="text: checked() ? '☑' : '☐'"></div>
|
||||||
|
<div class="nameParent actionHandle" data-bind="text: display"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="b-list-footer-toolbar">
|
||||||
<div class="b-list-footer-toolbar thm-message-list-bottom-toolbar">
|
<div class="footer-pager">
|
||||||
<div class="footer-pager">
|
<!-- ko template: { name: 'Paginator', data: contactsPaginator } --><!-- /ko -->
|
||||||
<!-- ko template: { name: 'Paginator', data: contactsPaginator } --><!-- /ko -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form class="b-view-content" spellcheck="false" data-bind="css: {'read-only': contact() && contact().readOnly()}">
|
<div class="right">
|
||||||
<div class="b-contact-view-desc" data-bind="visible: !contact()"
|
<form class="b-view-content" spellcheck="false" data-bind="css: {'read-only': contact() && contact().readOnly()}">
|
||||||
data-i18n="CONTACTS/CONTACT_VIEW_DESC"></div>
|
<div class="b-contact-view-desc" data-bind="visible: !contact()"
|
||||||
<div class="tabs" data-bind="visible: contact, i18nUpdate: contact">
|
data-i18n="CONTACTS/CONTACT_VIEW_DESC"></div>
|
||||||
<!-- ko with: contact -->
|
<div class="tabs" data-bind="visible: contact, i18nUpdate: contact">
|
||||||
<input type="radio" name="contacttabs" id="tab-contact" checked>
|
<!-- ko with: contact -->
|
||||||
<label for="tab-contact"
|
<input type="radio" name="contacttabs" id="tab-contact" checked>
|
||||||
role="tab"
|
<label for="tab-contact"
|
||||||
aria-selected="true"
|
role="tab"
|
||||||
aria-controls="panel1"
|
aria-selected="true"
|
||||||
tabindex="0"
|
aria-controls="panel1"
|
||||||
data-i18n="CONTACTS/TAB_CONTACT"></label>
|
tabindex="0"
|
||||||
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
data-i18n="CONTACTS/TAB_CONTACT"></label>
|
||||||
<div class="control-group">
|
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
||||||
<label class="fontastic iconsize24">👤</label>
|
<div class="control-group">
|
||||||
<div>
|
<label class="fontastic iconsize24">👤</label>
|
||||||
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME">
|
<div>
|
||||||
<span data-bind="text: givenName"></span>
|
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME">
|
||||||
<input type="text"
|
<span data-bind="text: givenName"></span>
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
<input type="text"
|
||||||
data-bind="textInput: givenName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME">
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
</div>
|
data-bind="textInput: givenName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME">
|
||||||
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_LAST_NAME">
|
</div>
|
||||||
<span data-bind="text: surName"></span>
|
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_LAST_NAME">
|
||||||
<input type="text"
|
<span data-bind="text: surName"></span>
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
<input type="text"
|
||||||
data-bind="textInput: surName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_LAST_NAME">
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
</div>
|
data-bind="textInput: surName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_LAST_NAME">
|
||||||
|
</div>
|
||||||
<!--
|
<!--
|
||||||
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_MIDDLE_NAME">
|
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_MIDDLE_NAME">
|
||||||
<span data-bind="text: middleName"></span>
|
<span data-bind="text: middleName"></span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
data-bind="textInput: middleName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_MIDDLE_NAME">
|
data-bind="textInput: middleName" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_MIDDLE_NAME">
|
||||||
</div>
|
</div>
|
||||||
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_NAME_PREFIX">
|
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_NAME_PREFIX">
|
||||||
<span data-bind="text: namePrefix"></span>
|
<span data-bind="text: namePrefix"></span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
data-bind="textInput: namePrefix" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NAME_PREFIX">
|
data-bind="textInput: namePrefix" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NAME_PREFIX">
|
||||||
</div>
|
</div>
|
||||||
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_NAME_SUFFIX">
|
<div class="property-line" data-i18n="[title]CONTACTS/PLACEHOLDER_ENTER_NAME_SUFFIX">
|
||||||
<span data-bind="text: nameSuffix"></span>
|
<span data-bind="text: nameSuffix"></span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
data-bind="textInput: nameSuffix" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NAME_SUFFIX">
|
data-bind="textInput: nameSuffix" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NAME_SUFFIX">
|
||||||
</div>
|
</div>
|
||||||
-->
|
-->
|
||||||
<div class="property-line" data-bind="visible: null != nickname()" data-i18n="[title]CONTACTS/ADD_MENU_NICKNAME">
|
<div class="property-line" data-bind="visible: null != nickname()" data-i18n="[title]CONTACTS/ADD_MENU_NICKNAME">
|
||||||
<span data-bind="text: nickname"></span>
|
<span data-bind="text: nickname"></span>
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: nickname" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NICK_NAME">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" data-bind="email().length">
|
||||||
|
<label class="fontastic iconsize24" data-i18n="[title]GLOBAL/EMAIL">@</label>
|
||||||
|
<div>
|
||||||
|
<div data-bind="foreach: email">
|
||||||
|
<div class="property-line">
|
||||||
|
<span data-bind="text: value"></span>
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addEmail">✚</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" data-bind="visible: tel().length">
|
||||||
|
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_PHONE">📞</label>
|
||||||
|
<div>
|
||||||
|
<div data-bind="foreach: tel">
|
||||||
|
<div class="property-line">
|
||||||
|
<span data-bind="text: value"></span>
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addTel">✚</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" data-bind="visible: url().length">
|
||||||
|
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_URL">🌍</label>
|
||||||
|
<div>
|
||||||
|
<div data-bind="foreach: url">
|
||||||
|
<div class="property-line">
|
||||||
|
<span data-bind="text: value"></span>
|
||||||
|
<input type="text" placeholder="https://"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addUrl">✚</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" data-bind="visible: null != note()">
|
||||||
|
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_NOTE">🖉</label>
|
||||||
|
<div class="property-line">
|
||||||
|
<span data-bind="text: note"></span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
data-bind="textInput: nickname" data-i18n="[placeholder]CONTACTS/PLACEHOLDER_ENTER_NICK_NAME">
|
data-bind="textInput: note">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" data-bind="email().length">
|
<!--
|
||||||
<label class="fontastic iconsize24" data-i18n="[title]GLOBAL/EMAIL">@</label>
|
<input type="radio" name="contacttabs" id="tab-contact-locations">
|
||||||
<div>
|
<label for="tab-contact-locations"
|
||||||
<div data-bind="foreach: email">
|
role="tab"
|
||||||
<div class="property-line">
|
aria-selected="false"
|
||||||
<span data-bind="text: value"></span>
|
aria-controls="panel2"
|
||||||
<input type="text"
|
tabindex="0"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
data-i18n="CONTACTS/TAB_LOCATIONS"></label>
|
||||||
data-bind="textInput: value">
|
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
||||||
</div>
|
<div data-bind="foreach: adr">
|
||||||
|
<div class="control-group">
|
||||||
|
<div data-bind="text: type"></div>
|
||||||
|
<div data-bind="text: street"></div>
|
||||||
|
<div data-bind="text: street_ext"></div>
|
||||||
|
<div data-bind="text: locality"></div>
|
||||||
|
<div data-bind="text: region"></div>
|
||||||
|
<div data-bind="text: postcode"></div>
|
||||||
|
<div data-bind="text: pobox"></div>
|
||||||
|
<div data-bind="text: country"></div>
|
||||||
|
<div data-bind="text: preferred"></div>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addEmail">✚</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" data-bind="visible: tel().length">
|
-->
|
||||||
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_PHONE">📞</label>
|
<input type="radio" name="contacttabs" id="tab-contact-business">
|
||||||
<div>
|
<label for="tab-contact-business"
|
||||||
<div data-bind="foreach: tel">
|
role="tab"
|
||||||
<div class="property-line">
|
aria-selected="false"
|
||||||
<span data-bind="text: value"></span>
|
aria-controls="panel3"
|
||||||
<input type="text"
|
tabindex="0"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
data-i18n="CONTACTS/TAB_BUSINESS"></label>
|
||||||
data-bind="textInput: value">
|
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
||||||
</div>
|
<div class="control-group">
|
||||||
</div>
|
<label data-i18n="CONTACTS/LABEL_ORGANIZATION"></label>
|
||||||
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addTel">✚</a>
|
<span data-bind="text: org"></span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control-group" data-bind="visible: url().length">
|
|
||||||
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_URL">🌍</label>
|
|
||||||
<div>
|
|
||||||
<div data-bind="foreach: url">
|
|
||||||
<div class="property-line">
|
|
||||||
<span data-bind="text: value"></span>
|
|
||||||
<input type="text" placeholder="https://"
|
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
|
||||||
data-bind="textInput: value">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="#" class="btn fontastic" data-bind="visible: !readOnly(), click: addUrl">✚</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control-group" data-bind="visible: null != note()">
|
|
||||||
<label class="fontastic iconsize24" data-i18n="[title]CONTACTS/ADD_MENU_NOTE">🖉</label>
|
|
||||||
<div class="property-line">
|
|
||||||
<span data-bind="text: note"></span>
|
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
data-bind="textInput: note">
|
data-bind="textInput: org">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label data-i18n="CONTACTS/LABEL_DEPARTMENT"></label>
|
||||||
|
<span data-bind="text: department"></span>
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: department">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label data-i18n="CONTACTS/LABEL_TITLE"></label>
|
||||||
|
<span data-bind="text: title"></span>
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
|
data-bind="textInput: title">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="radio" name="contacttabs" id="tab-contact-crypto">
|
||||||
|
<label for="tab-contact-crypto"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
aria-controls="panel4"
|
||||||
|
tabindex="0"
|
||||||
|
data-i18n="CONTACTS/TAB_CRYPTO"></label>
|
||||||
|
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
||||||
|
<div class="control-group">
|
||||||
|
<label data-i18n="OPENPGP/LABEL_SIGN"></label>
|
||||||
|
<select name="x-crypto[signpref]" data-bind="value: signpref">
|
||||||
|
<option value="Ask" data-i18n="CONTACTS/ASK"></option>
|
||||||
|
<option value="Never" data-i18n="CONTACTS/NEVER"></option>
|
||||||
|
<option value="Always" data-i18n="CONTACTS/ALWAYS"></option>
|
||||||
|
<option value="IfPossible" data-i18n="CONTACTS/ALWAYS_IF_POSSIBLE"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label data-i18n="OPENPGP/LABEL_ENCRYPT"></label>
|
||||||
|
<select name="x-crypto[encryptpref]" data-bind="value: encryptpref">
|
||||||
|
<option value="Ask" data-i18n="CONTACTS/ASK"></option>
|
||||||
|
<option value="Never" data-i18n="CONTACTS/NEVER"></option>
|
||||||
|
<option value="Always" data-i18n="CONTACTS/ALWAYS"></option>
|
||||||
|
<option value="IfPossible" data-i18n="CONTACTS/ALWAYS_IF_POSSIBLE"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /ko -->
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="b-view-content-toolbar btn-toolbar" data-bind="i18nUpdate: contact">
|
||||||
|
<!-- ko with: contact -->
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn button-save-contact" data-bind="visible: !readOnly(), command: $root.saveCommand">
|
||||||
|
<i data-bind="css: {'icon-ok': !$root.isSaving(), 'icon-spinner': $root.isSaving()}"></i>
|
||||||
|
<span data-i18n="CONTACTS/BUTTON_CREATE_CONTACT" data-bind="visible: !id()"></span>
|
||||||
|
<span data-i18n="CONTACTS/BUTTON_UPDATE_CONTACT" data-bind="visible: id"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group dropdown" data-bind="visible: !readOnly(), registerBootstrapDropdown: true">
|
||||||
|
<a id="button-add-prop-dropdown-id" href="#" class="btn dropdown-toggle" data-i18n="CONTACTS/ADD_MENU_LABEL"></a>
|
||||||
|
<menu class="dropdown-menu right-edge" style="text-align: left" role="menu" aria-labelledby="button-add-prop-dropdown-id">
|
||||||
|
<li role="presentation" data-bind="visible: null == nickname()">
|
||||||
|
<a href="#" data-bind="click: addNickname">
|
||||||
|
<i class="icon-none"></i>
|
||||||
|
<span data-i18n="CONTACTS/ADD_MENU_NICKNAME"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation">
|
||||||
|
<a href="#" data-bind="click: addEmail">
|
||||||
|
<i class="fontastic">@</i>
|
||||||
|
<span data-i18n="GLOBAL/EMAIL"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation">
|
||||||
|
<a href="#" data-bind="click: addTel">
|
||||||
|
<i class="fontastic">📞</i>
|
||||||
|
<span data-i18n="CONTACTS/ADD_MENU_PHONE"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation">
|
||||||
|
<a href="#" data-bind="click: addUrl">
|
||||||
|
<i class="fontastic">🌍</i>
|
||||||
|
<span data-i18n="CONTACTS/ADD_MENU_URL"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation" data-bind="visible: null == note()">
|
||||||
|
<a href="#" data-bind="click: addNote">
|
||||||
|
<i class="fontastic">🖉</i>
|
||||||
|
<span data-i18n="CONTACTS/ADD_MENU_NOTE"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!--
|
||||||
|
<li class="dividerbar" role="presentation">
|
||||||
|
<a href="#" data-bind="click: addNewAddress">
|
||||||
|
<span data-i18n="CONTACTS/ADD_MENU_ADDRESS"></span>
|
||||||
|
</a>
|
||||||
|
</li>-->
|
||||||
|
</menu>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
<!--
|
||||||
<input type="radio" name="contacttabs" id="tab-contact-locations">
|
<div class="read-only-sign fontastic iconsize24" data-bind="visible: readOnly" data-i18n="[title]CONTACTS/LABEL_READ_ONLY">🔒</div>
|
||||||
<label for="tab-contact-locations"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
aria-controls="panel2"
|
|
||||||
tabindex="0"
|
|
||||||
data-i18n="CONTACTS/TAB_LOCATIONS"></label>
|
|
||||||
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
|
||||||
<div data-bind="foreach: adr">
|
|
||||||
<div class="control-group">
|
|
||||||
<div data-bind="text: type"></div>
|
|
||||||
<div data-bind="text: street"></div>
|
|
||||||
<div data-bind="text: street_ext"></div>
|
|
||||||
<div data-bind="text: locality"></div>
|
|
||||||
<div data-bind="text: region"></div>
|
|
||||||
<div data-bind="text: postcode"></div>
|
|
||||||
<div data-bind="text: pobox"></div>
|
|
||||||
<div data-bind="text: country"></div>
|
|
||||||
<div data-bind="text: preferred"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
-->
|
-->
|
||||||
<input type="radio" name="contacttabs" id="tab-contact-business">
|
|
||||||
<label for="tab-contact-business"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
aria-controls="panel3"
|
|
||||||
tabindex="0"
|
|
||||||
data-i18n="CONTACTS/TAB_BUSINESS"></label>
|
|
||||||
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
|
||||||
<div class="control-group">
|
|
||||||
<label data-i18n="CONTACTS/LABEL_ORGANIZATION"></label>
|
|
||||||
<span data-bind="text: org"></span>
|
|
||||||
<input type="text"
|
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
|
||||||
data-bind="textInput: org">
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label data-i18n="CONTACTS/LABEL_DEPARTMENT"></label>
|
|
||||||
<span data-bind="text: department"></span>
|
|
||||||
<input type="text"
|
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
|
||||||
data-bind="textInput: department">
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label data-i18n="CONTACTS/LABEL_TITLE"></label>
|
|
||||||
<span data-bind="text: title"></span>
|
|
||||||
<input type="text"
|
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off"
|
|
||||||
data-bind="textInput: title">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="radio" name="contacttabs" id="tab-contact-crypto">
|
|
||||||
<label for="tab-contact-crypto"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
aria-controls="panel4"
|
|
||||||
tabindex="0"
|
|
||||||
data-i18n="CONTACTS/TAB_CRYPTO"></label>
|
|
||||||
<div class="form-horizontal tab-content" role="tabpanel" aria-hidden="false">
|
|
||||||
<div class="control-group">
|
|
||||||
<label data-i18n="OPENPGP/LABEL_SIGN"></label>
|
|
||||||
<select name="x-crypto[signpref]" data-bind="value: signpref">
|
|
||||||
<option value="Ask" data-i18n="CONTACTS/ASK"></option>
|
|
||||||
<option value="Never" data-i18n="CONTACTS/NEVER"></option>
|
|
||||||
<option value="Always" data-i18n="CONTACTS/ALWAYS"></option>
|
|
||||||
<option value="IfPossible" data-i18n="CONTACTS/ALWAYS_IF_POSSIBLE"></option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label data-i18n="OPENPGP/LABEL_ENCRYPT"></label>
|
|
||||||
<select name="x-crypto[encryptpref]" data-bind="value: encryptpref">
|
|
||||||
<option value="Ask" data-i18n="CONTACTS/ASK"></option>
|
|
||||||
<option value="Never" data-i18n="CONTACTS/NEVER"></option>
|
|
||||||
<option value="Always" data-i18n="CONTACTS/ALWAYS"></option>
|
|
||||||
<option value="IfPossible" data-i18n="CONTACTS/ALWAYS_IF_POSSIBLE"></option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /ko -->
|
<!-- /ko -->
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
<div class="b-view-content-toolbar btn-toolbar" data-bind="i18nUpdate: contact">
|
|
||||||
<!-- ko with: contact -->
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn button-save-contact" data-bind="visible: !readOnly(), command: $root.saveCommand">
|
|
||||||
<i data-bind="css: {'icon-ok': !$root.isSaving(), 'icon-spinner': $root.isSaving()}"></i>
|
|
||||||
<span data-i18n="CONTACTS/BUTTON_CREATE_CONTACT" data-bind="visible: !id()"></span>
|
|
||||||
<span data-i18n="CONTACTS/BUTTON_UPDATE_CONTACT" data-bind="visible: id"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group dropdown" data-bind="visible: !readOnly(), registerBootstrapDropdown: true">
|
|
||||||
<a id="button-add-prop-dropdown-id" href="#" class="btn dropdown-toggle" data-i18n="CONTACTS/ADD_MENU_LABEL"></a>
|
|
||||||
<menu class="dropdown-menu right-edge" style="text-align: left" role="menu" aria-labelledby="button-add-prop-dropdown-id">
|
|
||||||
<li role="presentation" data-bind="visible: null == nickname()">
|
|
||||||
<a href="#" data-bind="click: addNickname">
|
|
||||||
<i class="icon-none"></i>
|
|
||||||
<span data-i18n="CONTACTS/ADD_MENU_NICKNAME"></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li role="presentation">
|
|
||||||
<a href="#" data-bind="click: addEmail">
|
|
||||||
<i class="fontastic">@</i>
|
|
||||||
<span data-i18n="GLOBAL/EMAIL"></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li role="presentation">
|
|
||||||
<a href="#" data-bind="click: addTel">
|
|
||||||
<i class="fontastic">📞</i>
|
|
||||||
<span data-i18n="CONTACTS/ADD_MENU_PHONE"></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li role="presentation">
|
|
||||||
<a href="#" data-bind="click: addUrl">
|
|
||||||
<i class="fontastic">🌍</i>
|
|
||||||
<span data-i18n="CONTACTS/ADD_MENU_URL"></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li role="presentation" data-bind="visible: null == note()">
|
|
||||||
<a href="#" data-bind="click: addNote">
|
|
||||||
<i class="fontastic">🖉</i>
|
|
||||||
<span data-i18n="CONTACTS/ADD_MENU_NOTE"></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<!--
|
|
||||||
<li class="dividerbar" role="presentation">
|
|
||||||
<a href="#" data-bind="click: addNewAddress">
|
|
||||||
<span data-i18n="CONTACTS/ADD_MENU_ADDRESS"></span>
|
|
||||||
</a>
|
|
||||||
</li>-->
|
|
||||||
</menu>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
<div class="read-only-sign fontastic iconsize24" data-bind="visible: readOnly" data-i18n="[title]CONTACTS/LABEL_READ_ONLY">🔒</div>
|
|
||||||
-->
|
|
||||||
<!-- /ko -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
</header>
|
</header>
|
||||||
<form id="openpgp-generate" class="modal-body form-horizontal" autocomplete="off" spellcheck="false" data-bind="submit: submitForm">
|
<form id="openpgp-generate" class="modal-body form-horizontal" autocomplete="off" spellcheck="false" data-bind="submit: submitForm">
|
||||||
<div class="alert" data-bind="visible: '' !== submitError()">
|
<div class="alert" data-bind="visible: '' !== submitError()">
|
||||||
<a href="#" class="close" data-bind="click: function () { submitError('') }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: submitError"></span>
|
<span data-bind="text: submitError"></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Disable stupid browser password autofill -->
|
<!-- Disable stupid browser password autofill -->
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<span data-i18n="SETTINGS_FOLDERS/TO_MANY_FOLDERS_DESC_2"></span>
|
<span data-i18n="SETTINGS_FOLDERS/TO_MANY_FOLDERS_DESC_2"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert folders-list-error" data-bind="visible: '' !== folderListError()">
|
<div class="alert folders-list-error" data-bind="visible: '' !== folderListError()">
|
||||||
<a href="#" class="close" data-bind="click: function () { folderListError(''); }">×</a>
|
<a href="#" class="close" data-bind="click: hideError">×</a>
|
||||||
<span data-bind="text: folderListError"></span>
|
<span data-bind="text: folderListError"></span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover list-table" data-bind="i18nUpdate: folderList">
|
<table class="table table-hover list-table" data-bind="i18nUpdate: folderList">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<span data-bind="saveTrigger: themeTrigger"></span>
|
<span data-bind="saveTrigger: themeTrigger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div data-bind="foreach: themesObjects">
|
<div data-bind="foreach: themesObjects">
|
||||||
<figure data-bind="click: function () { $root.theme(name); }, css: { 'selected': selected }">
|
<figure data-bind="click: $root.setTheme, css: { 'selected': selected }">
|
||||||
<figcaption data-bind="text: nameDisplay"></figcaption>
|
<figcaption data-bind="text: nameDisplay"></figcaption>
|
||||||
<img data-bind="attr: { 'src': themePreviewSrc }">
|
<img data-bind="attr: { 'src': themePreviewSrc }">
|
||||||
</figure>
|
</figure>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue