domma-cms 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/action-editor-arrange.js +1 -0
- package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/block-editor-arrange.js +2 -0
- package/admin/js/lib/collection-editor-arrange.js +3 -0
- package/admin/js/lib/collection-entries-arrange.js +1 -0
- package/admin/js/lib/component-editor-arrange.js +27 -0
- package/admin/js/lib/context-menu-editor-arrange.js +1 -0
- package/admin/js/lib/dashboard-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/form-editor-arrange.js +1 -0
- package/admin/js/lib/form-submissions-arrange.js +2 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/page-editor-arrange.js +1 -0
- package/admin/js/lib/plugin-code-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/project-page-arrange.js +1 -0
- package/admin/js/lib/role-editor-arrange.js +3 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/user-editor-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/view-editor-arrange.js +14 -0
- package/admin/js/templates/action-editor.html +223 -147
- package/admin/js/templates/api-endpoint-editor.html +172 -117
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/block-editor.html +194 -203
- package/admin/js/templates/collection-editor.html +191 -113
- package/admin/js/templates/collection-entries.html +99 -36
- package/admin/js/templates/component-editor.html +128 -126
- package/admin/js/templates/context-menu-editor.html +208 -148
- package/admin/js/templates/dashboard.html +184 -22
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/project-detail.html +83 -145
- package/admin/js/templates/project-settings.html +88 -53
- package/admin/js/templates/role-editor.html +158 -83
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/user-editor.html +150 -14
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/view-editor.html +203 -180
- package/admin/js/templates/view-preview.html +45 -45
- package/admin/js/views/action-editor.js +7 -1
- package/admin/js/views/api-endpoint-editor.js +2 -3
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/block-editor.js +14 -8
- package/admin/js/views/collection-editor.js +64 -5
- package/admin/js/views/collection-entries.js +50 -1
- package/admin/js/views/component-editor.js +2 -28
- package/admin/js/views/context-menu-editor.js +54 -55
- package/admin/js/views/dashboard.js +7 -1
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +11 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +21 -43
- package/admin/js/views/menu-locations.js +16 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/project-detail.js +9 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +11 -3
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/users.js +65 -3
- package/server/routes/api/views.js +55 -1
- package/server/server.js +12 -0
- package/server/services/components.js +9 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +16 -2
- package/server/services/views.js +14 -0
- package/admin/js/templates/dashboard/activity-feed.html +0 -3
- package/admin/js/templates/dashboard/cache.html +0 -32
- package/admin/js/templates/dashboard/health-detail.html +0 -2
- package/admin/js/templates/dashboard/journeys.html +0 -17
- package/admin/js/templates/dashboard/kpi-strip.html +0 -34
- package/admin/js/templates/dashboard/spike-feed.html +0 -3
- package/admin/js/templates/dashboard/top-pages.html +0 -3
- package/admin/js/templates/dashboard/traffic-chart.html +0 -3
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/block-editor-enhance.js +0 -1
- package/admin/js/views/dashboard/lib/escape.js +0 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
- package/admin/js/views/dashboard/widgets/cache.js +0 -1
- package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
- package/admin/js/views/dashboard/widgets/journeys.js +0 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
- package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
- package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -36,8 +36,10 @@ const {
|
|
|
36
36
|
const {imageSendersSection} = await import(versioned('image-senders-section.js'));
|
|
37
37
|
const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
|
|
38
38
|
const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
|
|
39
|
-
|
|
39
|
+
// Renamed: onMount wraps it in its own `openCompose`, which tracks each window it opens.
|
|
40
|
+
const {openCompose: openComposeWindow} = await import(versioned('compose-window.js'));
|
|
40
41
|
const {makeResizable} = await import(versioned('resizable.js'));
|
|
42
|
+
const {whenClosed} = await import(versioned('when-closed.js'));
|
|
41
43
|
const {addContact, isKnownAddress, loadContacts} = await import(versioned('contacts.js'));
|
|
42
44
|
const {isPlainKey, isTyping, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
|
|
43
45
|
const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
@@ -105,6 +107,27 @@ function formatAddresses(list) {
|
|
|
105
107
|
return (list ?? []).map(a => a.name || a.address).filter(Boolean).join(', ');
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The admin's Tool helpers - loaded at run time (the updater replaces admin/
|
|
112
|
+
* wholesale, and an older admin without them must not take the view down),
|
|
113
|
+
* with stand-ins where one is missing.
|
|
114
|
+
*/
|
|
115
|
+
const toolKit = await import('/admin/js/lib/tool-kit.js').catch(() => ({}));
|
|
116
|
+
const viewLifetime = toolKit.viewLifetime ?? ((pattern) => {
|
|
117
|
+
let fns = [];
|
|
118
|
+
const run = () => { const list = fns; fns = []; list.forEach(fn => { try { fn?.(); } catch { /* gone */ } }); };
|
|
119
|
+
const onHash = () => { if (!pattern.test(location.hash)) run(); };
|
|
120
|
+
return {
|
|
121
|
+
add(fn) { fns.push(fn); },
|
|
122
|
+
reset() { run(); window.addEventListener('hashchange', onHash); fns.push(() => window.removeEventListener('hashchange', onHash)); }
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const helpPopovers = (await import('/admin/js/lib/help-popover.js').catch(() => ({}))).helpPopovers ?? (() => () => {});
|
|
126
|
+
const openMenuAt = toolKit.openMenuAt ?? ((el) => {
|
|
127
|
+
const r = el.getBoundingClientRect();
|
|
128
|
+
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
129
|
+
});
|
|
130
|
+
|
|
108
131
|
/**
|
|
109
132
|
* Human-readable byte size.
|
|
110
133
|
*
|
|
@@ -222,6 +245,12 @@ function ensureStylesheet() {
|
|
|
222
245
|
return ready;
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
// This screen's hashes. Domma's router has no unmount hook - `onUnmount` below
|
|
249
|
+
// was never called - so the view tears itself down when the address leaves
|
|
250
|
+
// them, and at the start of the next mount (a remount that never left).
|
|
251
|
+
const ROUTE = new RegExp(`^#\\/plugins\\/${pluginName}(?:[/?#]|$)`);
|
|
252
|
+
const lifetime = viewLifetime(ROUTE);
|
|
253
|
+
|
|
225
254
|
return {
|
|
226
255
|
templateUrl: `/plugins/_lib/admin/mail/mail.html?v=${assetVersion}`,
|
|
227
256
|
|
|
@@ -232,10 +261,16 @@ return {
|
|
|
232
261
|
* @returns {Promise<void>}
|
|
233
262
|
*/
|
|
234
263
|
async onMount($container) {
|
|
264
|
+
// Whatever the last visit left - listeners on document and window,
|
|
265
|
+
// context menus, compose windows - goes first.
|
|
266
|
+
lifetime.reset();
|
|
267
|
+
const here = () => ROUTE.test(location.hash) && Boolean($container.get(0)?.isConnected);
|
|
235
268
|
const stylesheetReady = ensureStylesheet();
|
|
236
269
|
|
|
237
|
-
// Held so
|
|
270
|
+
// Held so the teardown can detach the viewport listeners.
|
|
238
271
|
let stopFitting = null;
|
|
272
|
+
// Set once the layout is measured; `let` so an early teardown can read it.
|
|
273
|
+
let containerEl = null;
|
|
239
274
|
// ---------------------------------------------------------------
|
|
240
275
|
// State
|
|
241
276
|
// ---------------------------------------------------------------
|
|
@@ -427,6 +462,110 @@ return {
|
|
|
427
462
|
window.addEventListener('error', captureError);
|
|
428
463
|
window.addEventListener('unhandledrejection', captureError);
|
|
429
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Compose windows opened from this visit. Leaving the screen saves a
|
|
467
|
+
* changed one to Drafts (the mailbox's own, so it is there on the
|
|
468
|
+
* phone too) and closes it - it used to stay open over whichever
|
|
469
|
+
* screen came next, or vanish with the text on a reload.
|
|
470
|
+
*/
|
|
471
|
+
const composeHandles = new Set();
|
|
472
|
+
function openCompose(options) {
|
|
473
|
+
const handle = openComposeWindow(options);
|
|
474
|
+
if (handle) {
|
|
475
|
+
composeHandles.add(handle);
|
|
476
|
+
handle.onClosed?.(() => composeHandles.delete(handle));
|
|
477
|
+
}
|
|
478
|
+
return handle;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Settings and mailbox panels open from this visit: closed without
|
|
482
|
+
* asking when the screen is left (the lifetime), and removed from the
|
|
483
|
+
* page once closed - Domma hides a closed slideover rather than
|
|
484
|
+
* removing it.
|
|
485
|
+
*/
|
|
486
|
+
const openPanels = new Set();
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Guard a slideover: asks once before a close would throw away typed
|
|
490
|
+
* changes (Cancel, the X, Escape, the backdrop all call close()), and
|
|
491
|
+
* goes straight through after that - a later close() from cleanup must
|
|
492
|
+
* not ask again. No draft: these forms hold passwords.
|
|
493
|
+
*
|
|
494
|
+
* @param {object} slideover
|
|
495
|
+
* @param {Function} [dirty] - () => boolean
|
|
496
|
+
* @param {string} [question]
|
|
497
|
+
* @returns {object} the slideover
|
|
498
|
+
*/
|
|
499
|
+
function guardPanel(slideover, dirty = () => false, question = 'Discard the changes you have made?', onClosed = () => {}) {
|
|
500
|
+
const rawClose = slideover.close.bind(slideover);
|
|
501
|
+
let closed = false;
|
|
502
|
+
let asking = false;
|
|
503
|
+
slideover.close = async (force) => {
|
|
504
|
+
if (!closed && force !== true && dirty()) {
|
|
505
|
+
if (asking) return undefined;
|
|
506
|
+
asking = true;
|
|
507
|
+
const ok = await E.confirm(question);
|
|
508
|
+
asking = false;
|
|
509
|
+
if (!ok) return undefined;
|
|
510
|
+
}
|
|
511
|
+
if (closed) return undefined;
|
|
512
|
+
closed = true;
|
|
513
|
+
return rawClose();
|
|
514
|
+
};
|
|
515
|
+
openPanels.add(slideover);
|
|
516
|
+
whenClosed(slideover, () => {
|
|
517
|
+
closed = true;
|
|
518
|
+
openPanels.delete(slideover);
|
|
519
|
+
onClosed();
|
|
520
|
+
setTimeout(() => { try { slideover.element?.remove(); } catch { /* gone */ } }, 400);
|
|
521
|
+
});
|
|
522
|
+
return slideover;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const onBeforeUnload = (event) => {
|
|
526
|
+
if ([...composeHandles].some(h => h.dirty?.())) { event.preventDefault(); event.returnValue = ''; }
|
|
527
|
+
};
|
|
528
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Everything this visit put on document or window, and every menu and
|
|
532
|
+
* window it opened. Run by the lifetime when the address leaves the
|
|
533
|
+
* screen, and at the start of the next mount.
|
|
534
|
+
*
|
|
535
|
+
* @returns {void}
|
|
536
|
+
*/
|
|
537
|
+
function teardown() {
|
|
538
|
+
for (const handle of [...composeHandles]) {
|
|
539
|
+
if (handle.dirty?.()) {
|
|
540
|
+
handle.saveDraft?.().then((saved) => {
|
|
541
|
+
if (saved) E.toast(`The message you were writing was saved to ${saved.savedTo || 'Drafts'}.`, {type: 'info'});
|
|
542
|
+
else E.toast('The message you were writing could not be saved as a draft.', {type: 'warning'});
|
|
543
|
+
}).finally(() => handle.close?.(true));
|
|
544
|
+
} else {
|
|
545
|
+
handle.close?.(true);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
composeHandles.clear();
|
|
549
|
+
for (const panel of [...openPanels]) { try { panel.close(true); } catch { /* gone */ } }
|
|
550
|
+
openPanels.clear();
|
|
551
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
552
|
+
cancelMarkRead();
|
|
553
|
+
clearTimeout(undoTimer);
|
|
554
|
+
if (keyboardHandler) document.removeEventListener('keydown', keyboardHandler);
|
|
555
|
+
keyboardHandler = null;
|
|
556
|
+
window.removeEventListener('error', captureError);
|
|
557
|
+
window.removeEventListener('unhandledrejection', captureError);
|
|
558
|
+
disposeRowEffects();
|
|
559
|
+
messageMenu?.destroy?.();
|
|
560
|
+
messageMenu = null;
|
|
561
|
+
folderMenu?.destroy?.();
|
|
562
|
+
folderMenu = null;
|
|
563
|
+
stopFitting?.();
|
|
564
|
+
stopFitting = null;
|
|
565
|
+
containerEl?.classList.remove('mail-fullbleed');
|
|
566
|
+
}
|
|
567
|
+
lifetime.add(teardown);
|
|
568
|
+
|
|
430
569
|
/**
|
|
431
570
|
* Live effects belonging to the rows currently on screen.
|
|
432
571
|
*
|
|
@@ -578,7 +717,15 @@ return {
|
|
|
578
717
|
* @returns {void}
|
|
579
718
|
*/
|
|
580
719
|
function openSettings() {
|
|
581
|
-
|
|
720
|
+
// Help popovers per drawn section, let go when the panel closes.
|
|
721
|
+
const helpDisposers = [];
|
|
722
|
+
// Each section saves on its own button, so the panel itself has
|
|
723
|
+
// nothing to lose - guarded only to be closed with the screen.
|
|
724
|
+
const slideover = guardPanel(E.slideover({title: 'Mail Settings', size: 'lg', position: 'right'}),
|
|
725
|
+
() => false, undefined, () => {
|
|
726
|
+
stopSettingsResize();
|
|
727
|
+
helpDisposers.splice(0).forEach(fn => { try { fn?.(); } catch { /* gone */ } });
|
|
728
|
+
});
|
|
582
729
|
// Settings has tables of mailboxes and folder lists in it, so the
|
|
583
730
|
// width someone wants depends on their screen - the same reason
|
|
584
731
|
// compose is resizable. Its own remembered width, because the two
|
|
@@ -587,13 +734,13 @@ return {
|
|
|
587
734
|
storageKey: 'domma-mail.settings-width',
|
|
588
735
|
defaultWidth: 760
|
|
589
736
|
});
|
|
590
|
-
slideover.on?.('close', () => stopSettingsResize());
|
|
591
737
|
|
|
592
738
|
const sections = [
|
|
593
739
|
{
|
|
594
740
|
id: 'mailboxes',
|
|
595
741
|
label: 'Mailboxes',
|
|
596
742
|
icon: 'inbox',
|
|
743
|
+
help: 'The IMAP accounts this screen reads. Each person\'s mailboxes are their own - nobody else in the admin can see them. Passwords are stored sealed and never sent back to the browser.',
|
|
597
744
|
render: renderMailboxSection
|
|
598
745
|
},
|
|
599
746
|
// Blocking remote images is not a Pro feature, and neither is
|
|
@@ -667,6 +814,19 @@ return {
|
|
|
667
814
|
*/
|
|
668
815
|
async function draw(section, panel) {
|
|
669
816
|
panel.textContent = '';
|
|
817
|
+
// What the section is for, as a (?) at its head - the same help
|
|
818
|
+
// popover every Tool uses, so the sections explain themselves.
|
|
819
|
+
if (section.help) {
|
|
820
|
+
const head = document.createElement('div');
|
|
821
|
+
head.className = 'mail-section-intro';
|
|
822
|
+
const title = document.createElement('strong');
|
|
823
|
+
title.textContent = section.label;
|
|
824
|
+
const marker = document.createElement('span');
|
|
825
|
+
marker.setAttribute('data-help-title', section.label);
|
|
826
|
+
marker.setAttribute('data-help', section.help);
|
|
827
|
+
head.append(title, marker);
|
|
828
|
+
panel.appendChild(head);
|
|
829
|
+
}
|
|
670
830
|
try {
|
|
671
831
|
await section.render(panel, context());
|
|
672
832
|
} catch (err) {
|
|
@@ -676,6 +836,7 @@ return {
|
|
|
676
836
|
panel.appendChild(message);
|
|
677
837
|
}
|
|
678
838
|
Domma.icons.scan(panel);
|
|
839
|
+
helpDisposers.push(helpPopovers(panel, {position: 'bottom'}));
|
|
679
840
|
}
|
|
680
841
|
|
|
681
842
|
if (sections.length === 1) {
|
|
@@ -812,7 +973,6 @@ return {
|
|
|
812
973
|
storageKey: 'domma-mail.mailbox-form-width',
|
|
813
974
|
defaultWidth: 560
|
|
814
975
|
});
|
|
815
|
-
slideover.on?.('close', () => stopFormResize());
|
|
816
976
|
|
|
817
977
|
const form = document.createElement('div');
|
|
818
978
|
form.className = 'mail-form-stack';
|
|
@@ -890,6 +1050,12 @@ return {
|
|
|
890
1050
|
form.appendChild(actions);
|
|
891
1051
|
|
|
892
1052
|
slideover.element.appendChild(form);
|
|
1053
|
+
// What it opened with; a typed password always counts as a change.
|
|
1054
|
+
const opened = JSON.stringify({...collect(), password: ''});
|
|
1055
|
+
guardPanel(slideover,
|
|
1056
|
+
() => passInput.value !== '' || JSON.stringify({...collect(), password: ''}) !== opened,
|
|
1057
|
+
box ? 'Discard your changes to this mailbox?' : 'Discard this mailbox? It has not been saved.',
|
|
1058
|
+
() => stopFormResize());
|
|
893
1059
|
slideover.open();
|
|
894
1060
|
setTimeout(() => hostInput.focus(), 50);
|
|
895
1061
|
|
|
@@ -934,7 +1100,7 @@ return {
|
|
|
934
1100
|
const result = box
|
|
935
1101
|
? await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'PUT', collect())
|
|
936
1102
|
: await api(`${BASE}/accounts`, 'POST', collect());
|
|
937
|
-
slideover.close();
|
|
1103
|
+
slideover.close(true);
|
|
938
1104
|
E.toast('Mailbox saved.', {type: 'success'});
|
|
939
1105
|
currentAccountId = result.account.id;
|
|
940
1106
|
await start();
|
|
@@ -1434,8 +1600,8 @@ return {
|
|
|
1434
1600
|
// Rebuilt on every render, so a native title rather than a
|
|
1435
1601
|
// registered tooltip instance - see the E.tooltip call for why.
|
|
1436
1602
|
item.title = features.write
|
|
1437
|
-
? 'Click to open \u00b7 ctrl-click to select \u00b7 drag to a folder \u00b7 right-click for actions'
|
|
1438
|
-
: 'Click to open';
|
|
1603
|
+
? 'Click to open \u00b7 ctrl-click to select \u00b7 drag to a folder \u00b7 right-click, \u22ee or Shift+F10 for actions'
|
|
1604
|
+
: 'Click to open \u00b7 right-click, \u22ee or Shift+F10 for more';
|
|
1439
1605
|
|
|
1440
1606
|
// A span with the checkbox role, not an <input>: the row is a
|
|
1441
1607
|
// <button>, and a nested interactive element inside one is
|
|
@@ -1555,7 +1721,23 @@ return {
|
|
|
1555
1721
|
// A search spans folders, so a result carries its own - opening
|
|
1556
1722
|
// it against whatever folder is selected would look for the
|
|
1557
1723
|
// UID in the wrong mailbox and 404.
|
|
1724
|
+
// The ⋮: the same menu as a right-click. A span with the button
|
|
1725
|
+
// role rather than a <button> - the row IS a button, and the
|
|
1726
|
+
// click target decides, exactly as the tick does.
|
|
1727
|
+
const more = document.createElement('span');
|
|
1728
|
+
more.className = 'mail-item-more';
|
|
1729
|
+
more.setAttribute('role', 'button');
|
|
1730
|
+
more.setAttribute('aria-label', 'Actions');
|
|
1731
|
+
more.title = 'Actions';
|
|
1732
|
+
more.textContent = '\u22ee';
|
|
1733
|
+
item.appendChild(more);
|
|
1734
|
+
|
|
1558
1735
|
item.addEventListener('click', event => {
|
|
1736
|
+
if (event.target.closest('.mail-item-more')) {
|
|
1737
|
+
event.stopPropagation();
|
|
1738
|
+
openMenuAt(event.target.closest('.mail-item-more'));
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1559
1741
|
// The conversation chip opens the thread out rather than
|
|
1560
1742
|
// opening the message, whatever else is held. Checked
|
|
1561
1743
|
// first for the same reason the tick is: aiming at a
|
|
@@ -2411,6 +2593,8 @@ return {
|
|
|
2411
2593
|
* @returns {object[]}
|
|
2412
2594
|
*/
|
|
2413
2595
|
function messageMenuItems(message, folder) {
|
|
2596
|
+
// The free reader cannot write, so its menu is what reading allows.
|
|
2597
|
+
if (!features.write) return readOnlyMenuItems(message, folder);
|
|
2414
2598
|
const flags = message.flags$ ? message.flags$.value : (message.flags ?? []);
|
|
2415
2599
|
const isSeen = flags.includes('\\Seen');
|
|
2416
2600
|
const isFlagged = flags.includes('\\Flagged');
|
|
@@ -2442,7 +2626,14 @@ return {
|
|
|
2442
2626
|
icon: 'edit-3',
|
|
2443
2627
|
action: () => editDraft(target)
|
|
2444
2628
|
},
|
|
2445
|
-
{type: '
|
|
2629
|
+
{type: 'divider'}
|
|
2630
|
+
] : []),
|
|
2631
|
+
// The reading pane's three, without opening the message first.
|
|
2632
|
+
...(features.send && !isDraft(target) ? [
|
|
2633
|
+
{label: 'Reply', icon: 'corner-up-left', action: () => compose(target, 'reply')},
|
|
2634
|
+
{label: 'Reply all', icon: 'corner-up-right', action: () => compose(target, 'replyAll')},
|
|
2635
|
+
{label: 'Forward', icon: 'send', action: () => compose(target, 'forward')},
|
|
2636
|
+
{type: 'divider'}
|
|
2446
2637
|
] : []),
|
|
2447
2638
|
{
|
|
2448
2639
|
label: 'Mark as read',
|
|
@@ -2456,7 +2647,7 @@ return {
|
|
|
2456
2647
|
disabled: () => !isSeen,
|
|
2457
2648
|
action: () => changeFlags(target, {remove: ['\\Seen']})
|
|
2458
2649
|
},
|
|
2459
|
-
{type: '
|
|
2650
|
+
{type: 'divider'},
|
|
2460
2651
|
{
|
|
2461
2652
|
label: 'Star',
|
|
2462
2653
|
icon: 'star',
|
|
@@ -2478,9 +2669,9 @@ return {
|
|
|
2478
2669
|
disabled: () => senderKnown,
|
|
2479
2670
|
action: () => addSender(message)
|
|
2480
2671
|
},
|
|
2481
|
-
{type: '
|
|
2672
|
+
{type: 'divider'}
|
|
2482
2673
|
] : []),
|
|
2483
|
-
{type: '
|
|
2674
|
+
{type: 'divider'},
|
|
2484
2675
|
{
|
|
2485
2676
|
label: 'Mark as spam',
|
|
2486
2677
|
icon: 'shield-alert',
|
|
@@ -2495,7 +2686,7 @@ return {
|
|
|
2495
2686
|
disabled: () => !isSpam,
|
|
2496
2687
|
action: () => markSpam(target, false)
|
|
2497
2688
|
},
|
|
2498
|
-
{type: '
|
|
2689
|
+
{type: 'divider'},
|
|
2499
2690
|
...(features.snooze ? [{
|
|
2500
2691
|
label: 'Snooze\u2026',
|
|
2501
2692
|
icon: 'clock',
|
|
@@ -2510,7 +2701,7 @@ return {
|
|
|
2510
2701
|
submenu: moveTargets
|
|
2511
2702
|
},
|
|
2512
2703
|
...(features.handoff ? [
|
|
2513
|
-
{type: '
|
|
2704
|
+
{type: 'divider'},
|
|
2514
2705
|
{
|
|
2515
2706
|
label: 'Save to the site',
|
|
2516
2707
|
icon: 'layout',
|
|
@@ -2528,7 +2719,7 @@ return {
|
|
|
2528
2719
|
]
|
|
2529
2720
|
}
|
|
2530
2721
|
] : []),
|
|
2531
|
-
{type: '
|
|
2722
|
+
{type: 'divider'},
|
|
2532
2723
|
{
|
|
2533
2724
|
label: 'Delete',
|
|
2534
2725
|
icon: 'trash',
|
|
@@ -2538,6 +2729,46 @@ return {
|
|
|
2538
2729
|
];
|
|
2539
2730
|
}
|
|
2540
2731
|
|
|
2732
|
+
/**
|
|
2733
|
+
* The menu for the free, read-only reader: what reading allows. It had
|
|
2734
|
+
* no menu at all, so right-click did nothing on a list that looks like
|
|
2735
|
+
* every other list in the admin.
|
|
2736
|
+
*
|
|
2737
|
+
* @param {object} message
|
|
2738
|
+
* @param {string} folder
|
|
2739
|
+
* @returns {object[]}
|
|
2740
|
+
*/
|
|
2741
|
+
function readOnlyMenuItems(message, folder) {
|
|
2742
|
+
const sender = (message.from ?? [])[0]?.address ?? '';
|
|
2743
|
+
const senderKnown = isKnownAddress(addressBook, sender);
|
|
2744
|
+
return [
|
|
2745
|
+
{label: 'Open', icon: 'mail-open', action: () => openMessage(message.uid, folder)},
|
|
2746
|
+
{type: 'divider'},
|
|
2747
|
+
{label: 'Copy sender\u2019s address', icon: 'copy', disabled: () => !sender, action: () => copyText(sender, 'Address copied.')},
|
|
2748
|
+
{label: 'Copy subject', icon: 'copy', disabled: () => !message.subject, action: () => copyText(message.subject ?? '', 'Subject copied.')},
|
|
2749
|
+
...(contactsAvailable ? [
|
|
2750
|
+
{type: 'divider'},
|
|
2751
|
+
{label: 'Add sender to contacts', icon: 'user-plus', disabled: () => senderKnown || !sender, action: () => addSender(message)}
|
|
2752
|
+
] : [])
|
|
2753
|
+
];
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/**
|
|
2757
|
+
* Put text on the clipboard, saying so - and saying so when it could not.
|
|
2758
|
+
*
|
|
2759
|
+
* @param {string} text
|
|
2760
|
+
* @param {string} done
|
|
2761
|
+
* @returns {Promise<void>}
|
|
2762
|
+
*/
|
|
2763
|
+
async function copyText(text, done) {
|
|
2764
|
+
try {
|
|
2765
|
+
await navigator.clipboard.writeText(text);
|
|
2766
|
+
E.toast(done, {type: 'success'});
|
|
2767
|
+
} catch {
|
|
2768
|
+
E.toast('The clipboard is not available here - select and copy it from the message instead.', {type: 'warning'});
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2541
2772
|
/**
|
|
2542
2773
|
* Move a message, and take it out of the list it is leaving.
|
|
2543
2774
|
*
|
|
@@ -2978,7 +3209,7 @@ return {
|
|
|
2978
3209
|
modal.element.appendChild(panel);
|
|
2979
3210
|
// Closing the window any other way is a decision too, and a
|
|
2980
3211
|
// promise nobody settles is a handler that never runs again.
|
|
2981
|
-
modal
|
|
3212
|
+
whenClosed(modal, () => { if (!answered) resolve(null); });
|
|
2982
3213
|
modal.open();
|
|
2983
3214
|
});
|
|
2984
3215
|
}
|
|
@@ -3531,7 +3762,7 @@ return {
|
|
|
3531
3762
|
icon: 'folder-plus',
|
|
3532
3763
|
action: () => createFolder(folder.path)
|
|
3533
3764
|
},
|
|
3534
|
-
{type: '
|
|
3765
|
+
{type: 'divider'},
|
|
3535
3766
|
{
|
|
3536
3767
|
label: folder.subscribed === false ? 'Show in the list' : 'Hide from the list',
|
|
3537
3768
|
icon: folder.subscribed === false ? 'eye' : 'eye-off',
|
|
@@ -3542,7 +3773,7 @@ return {
|
|
|
3542
3773
|
disabled: () => isInbox,
|
|
3543
3774
|
action: () => setFolderVisible(folder, folder.subscribed === false)
|
|
3544
3775
|
},
|
|
3545
|
-
{type: '
|
|
3776
|
+
{type: 'divider'},
|
|
3546
3777
|
{
|
|
3547
3778
|
label: 'Rename',
|
|
3548
3779
|
icon: 'edit-3',
|
|
@@ -3682,7 +3913,7 @@ return {
|
|
|
3682
3913
|
if (event.key === 'Enter') create.click();
|
|
3683
3914
|
});
|
|
3684
3915
|
// Closing by the X or the backdrop is a cancel, not a hang.
|
|
3685
|
-
modal
|
|
3916
|
+
whenClosed(modal, () => finish(null));
|
|
3686
3917
|
|
|
3687
3918
|
actions.appendChild(cancel);
|
|
3688
3919
|
actions.appendChild(create);
|
|
@@ -4349,17 +4580,49 @@ return {
|
|
|
4349
4580
|
|
|
4350
4581
|
attachKeyboard();
|
|
4351
4582
|
|
|
4352
|
-
|
|
4583
|
+
// Every edition gets the message menu; what is in it depends on what
|
|
4584
|
+
// this one can do (readOnlyMenuItems for the free reader).
|
|
4585
|
+
attachMessageMenu();
|
|
4353
4586
|
if (features.folders) attachFolderMenu();
|
|
4354
4587
|
|
|
4588
|
+
// Shift+F10 opens the menu of the focused row, as the keyboard's
|
|
4589
|
+
// right-click - the rows are buttons, so Enter already opens them.
|
|
4590
|
+
const onMenuKey = (event) => {
|
|
4591
|
+
if (!(event.key === 'F10' && event.shiftKey)) return;
|
|
4592
|
+
const row = event.target.closest?.('.mail-item, .mail-folder');
|
|
4593
|
+
if (!row) return;
|
|
4594
|
+
event.preventDefault();
|
|
4595
|
+
openMenuAt(row);
|
|
4596
|
+
};
|
|
4597
|
+
listEl?.addEventListener('keydown', onMenuKey);
|
|
4598
|
+
foldersEl?.addEventListener('keydown', onMenuKey);
|
|
4599
|
+
|
|
4355
4600
|
$container.find('#mail-settings-btn').on('click', () => openSettings());
|
|
4356
4601
|
|
|
4357
4602
|
// Settings is a screen-level control, so it belongs in the admin's
|
|
4358
4603
|
// plugin banner beside the licence - the same place every other plugin
|
|
4359
4604
|
// puts its cog. Imported at RUN time and survivable: an admin that
|
|
4360
4605
|
// predates addBannerAction keeps the button in the Folders pane.
|
|
4606
|
+
// Compose and Refresh are screen actions too, so they join it there;
|
|
4607
|
+
// each pane button goes only once its banner twin is in place.
|
|
4608
|
+
let bannerCompose = false;
|
|
4361
4609
|
try {
|
|
4362
4610
|
const chrome = await import('/admin/js/lib/plugin-chrome.js');
|
|
4611
|
+
if (features.send) {
|
|
4612
|
+
bannerCompose = Boolean(chrome.addBannerAction?.($container, {
|
|
4613
|
+
id: `${pluginName}-compose`,
|
|
4614
|
+
icon: 'edit-3',
|
|
4615
|
+
title: 'New message',
|
|
4616
|
+
onClick: () => startNewMessage()
|
|
4617
|
+
}));
|
|
4618
|
+
}
|
|
4619
|
+
const refreshPlaced = chrome.addBannerAction?.($container, {
|
|
4620
|
+
id: `${pluginName}-refresh`,
|
|
4621
|
+
icon: 'refresh-cw',
|
|
4622
|
+
title: 'Refresh',
|
|
4623
|
+
onClick: () => loadMessages()
|
|
4624
|
+
});
|
|
4625
|
+
if (refreshPlaced) $container.find('#mail-refresh-btn').get(0)?.remove();
|
|
4363
4626
|
const placed = chrome.addBannerAction?.($container, {
|
|
4364
4627
|
id: `${pluginName}-settings`,
|
|
4365
4628
|
icon: 'settings',
|
|
@@ -4367,7 +4630,8 @@ return {
|
|
|
4367
4630
|
onClick: () => openSettings()
|
|
4368
4631
|
});
|
|
4369
4632
|
if (placed) $container.find('#mail-settings-btn').get(0)?.remove();
|
|
4370
|
-
} catch { /* no banner: the pane
|
|
4633
|
+
} catch { /* no banner: the pane buttons stay */ }
|
|
4634
|
+
if (!here()) return;
|
|
4371
4635
|
$container.find('#mail-refresh-btn').on('click', () => loadMessages());
|
|
4372
4636
|
|
|
4373
4637
|
// Folder management was right-click only, which is the same as not
|
|
@@ -4400,17 +4664,21 @@ return {
|
|
|
4400
4664
|
});
|
|
4401
4665
|
}
|
|
4402
4666
|
|
|
4667
|
+
/** A new, empty message - the banner's Compose and the pane's. */
|
|
4668
|
+
async function startNewMessage() {
|
|
4669
|
+
try {
|
|
4670
|
+
openCompose(composeOptions(await identitySoon(), {mode: 'new'}));
|
|
4671
|
+
} catch (err) {
|
|
4672
|
+
E.toast(err.message || 'Compose could not be opened.', {type: 'error'});
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4403
4676
|
if (features.send) {
|
|
4404
4677
|
const compose = $container.find('#mail-compose-btn').get(0);
|
|
4405
|
-
if (compose)
|
|
4678
|
+
if (compose && bannerCompose) compose.remove();
|
|
4679
|
+
else if (compose) {
|
|
4406
4680
|
compose.hidden = false;
|
|
4407
|
-
compose.addEventListener('click',
|
|
4408
|
-
try {
|
|
4409
|
-
openCompose(composeOptions(await identitySoon(), {mode: 'new'}));
|
|
4410
|
-
} catch (err) {
|
|
4411
|
-
E.toast(err.message || 'Compose could not be opened.', {type: 'error'});
|
|
4412
|
-
}
|
|
4413
|
-
});
|
|
4681
|
+
compose.addEventListener('click', () => startNewMessage());
|
|
4414
4682
|
}
|
|
4415
4683
|
}
|
|
4416
4684
|
|
|
@@ -4418,6 +4686,7 @@ return {
|
|
|
4418
4686
|
// Email Pro, which draws the same three panes.
|
|
4419
4687
|
// Nothing is measured until the stylesheet is in.
|
|
4420
4688
|
await stylesheetReady;
|
|
4689
|
+
if (!here()) return;
|
|
4421
4690
|
|
|
4422
4691
|
// Every hint in this view is a native `title`, deliberately, and NOT
|
|
4423
4692
|
// `E.tooltip`. That helper WRAPS each target in a <domma-tooltip>
|
|
@@ -4428,7 +4697,7 @@ return {
|
|
|
4428
4697
|
|
|
4429
4698
|
const layoutEl = $container.find('.mail-layout').get(0);
|
|
4430
4699
|
// Lift the admin's content width cap for this view only.
|
|
4431
|
-
|
|
4700
|
+
containerEl = layoutEl?.closest('#view-container') ?? null;
|
|
4432
4701
|
containerEl?.classList.add('mail-fullbleed');
|
|
4433
4702
|
|
|
4434
4703
|
if (layoutEl) {
|
|
@@ -4454,22 +4723,12 @@ return {
|
|
|
4454
4723
|
}
|
|
4455
4724
|
|
|
4456
4725
|
await start();
|
|
4726
|
+
if (!here()) return;
|
|
4457
4727
|
Domma.icons.scan($container.get(0));
|
|
4458
4728
|
|
|
4459
|
-
this.
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
if (keyboardHandler) document.removeEventListener('keydown', keyboardHandler);
|
|
4463
|
-
keyboardHandler = null;
|
|
4464
|
-
window.removeEventListener('error', captureError);
|
|
4465
|
-
window.removeEventListener('unhandledrejection', captureError);
|
|
4466
|
-
disposeRowEffects();
|
|
4467
|
-
messageMenu?.destroy?.();
|
|
4468
|
-
messageMenu = null;
|
|
4469
|
-
stopFitting?.();
|
|
4470
|
-
stopFitting = null;
|
|
4471
|
-
containerEl?.classList.remove('mail-fullbleed');
|
|
4472
|
-
};
|
|
4729
|
+
// Domma's router does not call this; the lifetime does the work. Kept so a
|
|
4730
|
+
// host that does call it gets the same teardown.
|
|
4731
|
+
this.onUnmount = teardown;
|
|
4473
4732
|
}
|
|
4474
4733
|
};
|
|
4475
4734
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run code when a Domma modal or slideover actually closes.
|
|
3
|
+
*
|
|
4
|
+
* NOT `component.on('close', fn)`: Domma's Component.on() only calls
|
|
5
|
+
* `options[event]` when that happens to be a function, so it silently does
|
|
6
|
+
* nothing - every "on close" cleanup in the mail client written that way
|
|
7
|
+
* (editor teardown, resize handles, address suggesters, settling a dialog's
|
|
8
|
+
* promise when it was closed by its X) never ran. `options.onClose` is what
|
|
9
|
+
* close() itself reads, at the moment it closes - so it also fires only when a
|
|
10
|
+
* close really happens, after any "discard changes?" guard has let it through.
|
|
11
|
+
*
|
|
12
|
+
* @module _lib/admin/mail/when-closed
|
|
13
|
+
*
|
|
14
|
+
* @param {object} component - an E.modal / E.slideover instance
|
|
15
|
+
* @param {Function} fn - called once per close, with the component
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function whenClosed(component, fn) {
|
|
19
|
+
const opts = component?.options;
|
|
20
|
+
if (!opts) return;
|
|
21
|
+
const previous = opts.onClose;
|
|
22
|
+
opts.onClose = (instance) => {
|
|
23
|
+
try { previous?.(instance); } catch { /* one cleanup failing must not stop the next */ }
|
|
24
|
+
try { fn(instance); } catch { /* ditto */ }
|
|
25
|
+
};
|
|
26
|
+
}
|