domma-cms 1.6.56 → 1.6.57
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/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -53,6 +53,12 @@ const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
|
53
53
|
// Sign in with Microsoft / Google (Email Pro). Optional: an older copy of this
|
|
54
54
|
// directory without it must not take the reader down.
|
|
55
55
|
const {describeSignIn, mountSignIn} = await import(versioned('oauth-panel.js')).catch(() => ({}));
|
|
56
|
+
const {
|
|
57
|
+
esc, mailboxDraftsKey, mailboxDraftId, mailboxDraftData, mailboxFingerprint, normaliseDraftMap,
|
|
58
|
+
pruneDraftMap, mailboxDraftDecision, mailboxRestoreQuestion, RESTORE_BUTTONS, settingsDiscardQuestion,
|
|
59
|
+
onceOnly, accountMenuModel, folderReadMenuModel, mailboxRowMenuModel, menuItems, sidebarUrl, hasNewMail,
|
|
60
|
+
holdEscapeOn
|
|
61
|
+
} = await import(versioned('reader-kit.js'));
|
|
56
62
|
|
|
57
63
|
/**
|
|
58
64
|
* Call a plugin route, returning parsed JSON.
|
|
@@ -137,6 +143,24 @@ const openMenuAt = toolKit.openMenuAt ?? ((el) => {
|
|
|
137
143
|
const r = el.getBoundingClientRect();
|
|
138
144
|
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
139
145
|
});
|
|
146
|
+
/**
|
|
147
|
+
* E.prompt with a starting value. Domma's `inputValue` goes into an attribute
|
|
148
|
+
* through its sanitiser and is cut at the first `"` (and `{value}`, which this
|
|
149
|
+
* view used to pass, is not an option at all) - core's promptWith sets the
|
|
150
|
+
* input's .value after the dialog is built.
|
|
151
|
+
*/
|
|
152
|
+
const promptWith = toolKit.promptWith ?? ((message, options, value) => {
|
|
153
|
+
const answer = E.prompt(message, options);
|
|
154
|
+
const input = [...document.querySelectorAll('.dm-dialog-input')].at(-1);
|
|
155
|
+
if (input) input.value = String(value ?? '');
|
|
156
|
+
return answer;
|
|
157
|
+
});
|
|
158
|
+
/** Tell the sidebar a Tool's badge may have moved (core's, or the same event). */
|
|
159
|
+
const sidebarCount = toolKit.sidebarCount ?? ((url) => {
|
|
160
|
+
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
|
|
161
|
+
});
|
|
162
|
+
/** Skeletons while a list's first load runs - core's helper, absent on an older admin. */
|
|
163
|
+
const loading = await import('/admin/js/lib/loading.js').catch(() => null);
|
|
140
164
|
|
|
141
165
|
/**
|
|
142
166
|
* Human-readable byte size.
|
|
@@ -516,44 +540,150 @@ return {
|
|
|
516
540
|
* removing it.
|
|
517
541
|
*/
|
|
518
542
|
const openPanels = new Set();
|
|
543
|
+
/** Panels that open once at a time, each released when it closes. */
|
|
544
|
+
const settingsOnce = onceOnly();
|
|
545
|
+
const mailboxFormOnce = onceOnly();
|
|
546
|
+
const outboxOnce = onceOnly();
|
|
547
|
+
const shortcutsOnce = onceOnly();
|
|
548
|
+
const dialogOnce = onceOnly();
|
|
519
549
|
|
|
520
550
|
/**
|
|
521
551
|
* Guard a slideover: asks once before a close would throw away typed
|
|
522
552
|
* changes (Cancel, the X, Escape, the backdrop all call close()), and
|
|
523
553
|
* goes straight through after that - a later close() from cleanup must
|
|
524
|
-
* not ask again.
|
|
554
|
+
* not ask again. A forced close (the screen being left) never asks:
|
|
555
|
+
* whatever is worth keeping is already in a draft.
|
|
556
|
+
*
|
|
557
|
+
* Cancel, Escape and the backdrop all answer "keep editing" - E.confirm
|
|
558
|
+
* cannot tell them apart - so OK is the one that discards.
|
|
525
559
|
*
|
|
526
560
|
* @param {object} slideover
|
|
527
561
|
* @param {Function} [dirty] - () => boolean
|
|
528
|
-
* @param {string}
|
|
562
|
+
* @param {string|Function} [question] - plain text (escaped here), or () => text
|
|
563
|
+
* @param {Function} [onClosed]
|
|
564
|
+
* @param {{onDiscard?: Function, confirmText?: string, cancelText?: string}} [options]
|
|
565
|
+
* `onDiscard` runs once the person has agreed to lose the changes
|
|
529
566
|
* @returns {object} the slideover
|
|
530
567
|
*/
|
|
531
|
-
function guardPanel(slideover, dirty = () => false, question = 'Discard the changes you have made?',
|
|
568
|
+
function guardPanel(slideover, dirty = () => false, question = 'Discard the changes you have made?',
|
|
569
|
+
onClosed = () => {}, {onDiscard = () => {}, confirmText = 'Discard', cancelText = 'Keep editing'} = {}) {
|
|
532
570
|
const rawClose = slideover.close.bind(slideover);
|
|
533
571
|
let closed = false;
|
|
534
572
|
let asking = false;
|
|
573
|
+
// Escape belongs to the top panel: the ones already open under
|
|
574
|
+
// this one stop answering it until this one has gone. Taken when
|
|
575
|
+
// it OPENS, not here - a panel whose building throws before
|
|
576
|
+
// open() must not leave the ones below deaf to Escape.
|
|
577
|
+
let releaseBelow = () => {};
|
|
578
|
+
const rawOpen = slideover.open.bind(slideover);
|
|
579
|
+
slideover.open = (...args) => {
|
|
580
|
+
if (!closed && !openPanels.has(slideover)) {
|
|
581
|
+
releaseBelow = holdEscapeBelow();
|
|
582
|
+
openPanels.add(slideover);
|
|
583
|
+
}
|
|
584
|
+
return rawOpen(...args);
|
|
585
|
+
};
|
|
586
|
+
/**
|
|
587
|
+
* Give up on a panel that failed before it opened: nothing held,
|
|
588
|
+
* nothing tracked, nothing left in the page.
|
|
589
|
+
*/
|
|
590
|
+
slideover.__mailAbandon = () => {
|
|
591
|
+
closed = true;
|
|
592
|
+
openPanels.delete(slideover);
|
|
593
|
+
releaseBelow();
|
|
594
|
+
try { onClosed(); } catch { /* it never opened */ }
|
|
595
|
+
try { slideover.destroy?.(); } catch { /* gone */ }
|
|
596
|
+
try { slideover.element?.remove(); } catch { /* gone */ }
|
|
597
|
+
};
|
|
535
598
|
slideover.close = async (force) => {
|
|
536
599
|
if (!closed && force !== true && dirty()) {
|
|
537
600
|
if (asking) return undefined;
|
|
538
601
|
asking = true;
|
|
539
|
-
|
|
540
|
-
|
|
602
|
+
let ok = false;
|
|
603
|
+
try {
|
|
604
|
+
const text = typeof question === 'function' ? question() : question;
|
|
605
|
+
ok = await ask(esc(text), {confirmText, cancelText});
|
|
606
|
+
} finally {
|
|
607
|
+
asking = false;
|
|
608
|
+
}
|
|
541
609
|
if (!ok) return undefined;
|
|
610
|
+
try { onDiscard(); } catch { /* the close still goes ahead */ }
|
|
542
611
|
}
|
|
543
612
|
if (closed) return undefined;
|
|
544
613
|
closed = true;
|
|
545
614
|
return rawClose();
|
|
546
615
|
};
|
|
547
|
-
openPanels.add(slideover);
|
|
548
616
|
whenClosed(slideover, () => {
|
|
549
617
|
closed = true;
|
|
550
618
|
openPanels.delete(slideover);
|
|
551
|
-
|
|
619
|
+
// After the key press that closed it has finished travelling -
|
|
620
|
+
// the panel below may still be about to hear the same Escape.
|
|
621
|
+
setTimeout(releaseBelow, 0);
|
|
622
|
+
try { onClosed(); } catch { /* the panel is going regardless */ }
|
|
552
623
|
setTimeout(() => { try { slideover.element?.remove(); } catch { /* gone */ } }, 400);
|
|
553
624
|
});
|
|
554
625
|
return slideover;
|
|
555
626
|
}
|
|
556
627
|
|
|
628
|
+
/**
|
|
629
|
+
* A Domma modal this view opened: closed when the screen goes, and
|
|
630
|
+
* taken out of the page once closed (a closed `<domma-modal>` stays
|
|
631
|
+
* in the document otherwise, one per use).
|
|
632
|
+
*
|
|
633
|
+
* @param {object} modal - from E.modal
|
|
634
|
+
* @param {Function} [onClosed]
|
|
635
|
+
* @returns {object} the modal
|
|
636
|
+
*/
|
|
637
|
+
function trackModal(modal, onClosed = () => {}) {
|
|
638
|
+
const entry = {close: () => { try { modal.close(); } catch { /* gone */ } }};
|
|
639
|
+
const releaseBelow = holdEscapeBelow();
|
|
640
|
+
openPanels.add(entry);
|
|
641
|
+
whenClosed(modal, () => {
|
|
642
|
+
openPanels.delete(entry);
|
|
643
|
+
setTimeout(releaseBelow, 0);
|
|
644
|
+
try { onClosed(); } catch { /* going regardless */ }
|
|
645
|
+
setTimeout(() => { try { modal.element?.remove(); } catch { /* gone */ } }, 400);
|
|
646
|
+
});
|
|
647
|
+
return modal;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* E.confirm, answered "Cancel" for you if the screen is left while it
|
|
652
|
+
* is open - so it does not linger over the next screen, and a caller
|
|
653
|
+
* checking here() afterwards does nothing. Ask so that Cancel is never
|
|
654
|
+
* the destructive answer: Escape and the backdrop answer the same.
|
|
655
|
+
* The text is the caller's to escape (see esc()).
|
|
656
|
+
*
|
|
657
|
+
* @param {string} html
|
|
658
|
+
* @param {object} [options]
|
|
659
|
+
* @returns {Promise<boolean>}
|
|
660
|
+
*/
|
|
661
|
+
async function ask(html, options = {}) {
|
|
662
|
+
// Escape answers the question; it must not close the panel too.
|
|
663
|
+
const releaseBelow = holdEscapeBelow();
|
|
664
|
+
const before = new Set(document.querySelectorAll('.dm-dialog-cancel'));
|
|
665
|
+
const answer = E.confirm(html, options);
|
|
666
|
+
const cancel = [...document.querySelectorAll('.dm-dialog-cancel')].find(b => !before.has(b));
|
|
667
|
+
const dismiss = () => { try { cancel?.click(); } catch { /* gone */ } };
|
|
668
|
+
const stop = lifetime.add(dismiss);
|
|
669
|
+
try {
|
|
670
|
+
return Boolean(await answer);
|
|
671
|
+
} finally {
|
|
672
|
+
if (typeof stop === 'function') stop();
|
|
673
|
+
setTimeout(releaseBelow, 0);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Hold Escape on every panel open right now (see holdEscapeOn).
|
|
679
|
+
*
|
|
680
|
+
* @returns {() => void} release them all
|
|
681
|
+
*/
|
|
682
|
+
function holdEscapeBelow() {
|
|
683
|
+
const releases = [...openPanels].map(panel => holdEscapeOn(panel?.options ? panel : null));
|
|
684
|
+
return () => releases.forEach(release => release());
|
|
685
|
+
}
|
|
686
|
+
|
|
557
687
|
const onBeforeUnload = (event) => {
|
|
558
688
|
if ([...composeHandles].some(h => h.dirty?.())) { event.preventDefault(); event.returnValue = ''; }
|
|
559
689
|
};
|
|
@@ -671,6 +801,31 @@ return {
|
|
|
671
801
|
Domma.icons.scan(el);
|
|
672
802
|
}
|
|
673
803
|
|
|
804
|
+
/**
|
|
805
|
+
* Wait for `work` with a skeleton at the foot of `host` - core's
|
|
806
|
+
* whileLoading (nothing for a quick answer, no flicker, gone on an
|
|
807
|
+
* error, which is rethrown). The slot is a plain BLOCK made for the
|
|
808
|
+
* occasion and taken out after: a flex row would collapse the
|
|
809
|
+
* skeleton to a sliver. An admin without the helper just waits.
|
|
810
|
+
*
|
|
811
|
+
* @param {HTMLElement} host
|
|
812
|
+
* @param {Function} work - returns a promise
|
|
813
|
+
* @param {string} [shape] - a key of core's SHAPES
|
|
814
|
+
* @returns {Promise<*>}
|
|
815
|
+
*/
|
|
816
|
+
async function withSkeleton(host, work, shape = 'rows') {
|
|
817
|
+
if (!host || typeof loading?.whileLoading !== 'function') return work();
|
|
818
|
+
const slot = document.createElement('div');
|
|
819
|
+
slot.className = 'mail-loading-slot';
|
|
820
|
+
slot.textContent = 'Loading\u2026';
|
|
821
|
+
host.appendChild(slot);
|
|
822
|
+
try {
|
|
823
|
+
return await loading.whileLoading(slot, work, loading.SHAPES?.[shape] ?? loading.SHAPES?.rows);
|
|
824
|
+
} finally {
|
|
825
|
+
slot.remove();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
674
829
|
/**
|
|
675
830
|
* Surface an error in the right place: a missing mailbox or an
|
|
676
831
|
* unopenable password are setup problems, everything else is a toast.
|
|
@@ -756,15 +911,43 @@ return {
|
|
|
756
911
|
* @returns {void}
|
|
757
912
|
*/
|
|
758
913
|
function openSettings() {
|
|
914
|
+
// One at a time: a second click, or a needs-password error while
|
|
915
|
+
// it is open, must not stack a second panel on the first.
|
|
916
|
+
if (!settingsOnce.claim()) return;
|
|
917
|
+
let panel = null;
|
|
918
|
+
try {
|
|
919
|
+
buildSettings((so) => { panel = so; });
|
|
920
|
+
} catch (err) {
|
|
921
|
+
// Failed before it opened: let go of everything it took.
|
|
922
|
+
panel?.__mailAbandon?.();
|
|
923
|
+
settingsOnce.release();
|
|
924
|
+
E.toast(err.message || 'Mail settings could not be opened.', {type: 'error'});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** @returns {void} */
|
|
929
|
+
function buildSettings(made = () => {}) {
|
|
759
930
|
// Help popovers per drawn section, let go when the panel closes.
|
|
760
931
|
const helpDisposers = [];
|
|
761
|
-
//
|
|
762
|
-
|
|
932
|
+
// What sections registered through ctx.guard / ctx.onClose.
|
|
933
|
+
const guards = new Set();
|
|
934
|
+
const closeFns = new Set();
|
|
935
|
+
const dirtyGuards = () => [...guards].filter(g => { try { return Boolean(g.dirty?.()); } catch { return false; } });
|
|
936
|
+
// Each section saves on its own button; the panel asks only for
|
|
937
|
+
// sections that said (ctx.guard) they hold unsaved work.
|
|
763
938
|
const slideover = guardPanel(E.slideover({title: 'Mail Settings', size: 'lg', position: 'right'}),
|
|
764
|
-
() =>
|
|
765
|
-
|
|
939
|
+
() => dirtyGuards().length > 0,
|
|
940
|
+
() => settingsDiscardQuestion(dirtyGuards().map(g => g.label)),
|
|
941
|
+
() => {
|
|
942
|
+
settingsOnce.release();
|
|
943
|
+
try { stopSettingsResize(); } catch { /* never made: the build failed first */ }
|
|
766
944
|
helpDisposers.splice(0).forEach(fn => { try { fn?.(); } catch { /* gone */ } });
|
|
767
|
-
|
|
945
|
+
for (const fn of [...closeFns]) { try { fn(); } catch { /* one failing must not stop the rest */ } }
|
|
946
|
+
closeFns.clear();
|
|
947
|
+
guards.clear();
|
|
948
|
+
},
|
|
949
|
+
{onDiscard: () => { for (const g of dirtyGuards()) { try { g.discard?.(); } catch { /* gone */ } } }});
|
|
950
|
+
made(slideover);
|
|
768
951
|
// Settings has tables of mailboxes and folder lists in it, so the
|
|
769
952
|
// width someone wants depends on their screen - the same reason
|
|
770
953
|
// compose is resizable. Its own remembered width, because the two
|
|
@@ -835,6 +1018,32 @@ return {
|
|
|
835
1018
|
assetVersion,
|
|
836
1019
|
field,
|
|
837
1020
|
close: () => slideover.close(),
|
|
1021
|
+
/**
|
|
1022
|
+
* Ask before the panel closes over this section's unsaved
|
|
1023
|
+
* work. One question names every section that has some;
|
|
1024
|
+
* OK runs each one's `discard`. A forced close (the screen
|
|
1025
|
+
* being left) never asks - keep a draft for that.
|
|
1026
|
+
*
|
|
1027
|
+
* @param {{label: string, dirty: Function, discard?: Function}} guard
|
|
1028
|
+
* @returns {Function} remove it
|
|
1029
|
+
*/
|
|
1030
|
+
guard(guard) {
|
|
1031
|
+
if (!guard || typeof guard.dirty !== 'function') return () => {};
|
|
1032
|
+
guards.add(guard);
|
|
1033
|
+
return () => guards.delete(guard);
|
|
1034
|
+
},
|
|
1035
|
+
/**
|
|
1036
|
+
* Run `fn` once when the settings panel closes, however it
|
|
1037
|
+
* closes (and when the screen is left).
|
|
1038
|
+
*
|
|
1039
|
+
* @param {Function} fn
|
|
1040
|
+
* @returns {Function} cancel it
|
|
1041
|
+
*/
|
|
1042
|
+
onClose(fn) {
|
|
1043
|
+
if (typeof fn !== 'function') return () => {};
|
|
1044
|
+
closeFns.add(fn);
|
|
1045
|
+
return () => closeFns.delete(fn);
|
|
1046
|
+
},
|
|
838
1047
|
/**
|
|
839
1048
|
* @param {{reload?: boolean}} [options]
|
|
840
1049
|
* @returns {Promise<void>}
|
|
@@ -937,33 +1146,188 @@ return {
|
|
|
937
1146
|
/**
|
|
938
1147
|
* The mailbox list: what the free edition's settings consist of.
|
|
939
1148
|
*
|
|
1149
|
+
* One menu for the rows - right-click a row, its ⋮, or Shift+F10 on
|
|
1150
|
+
* it - and Enter on a row edits it, the row's main action. Registered
|
|
1151
|
+
* on the section rather than the rows, because the rows are redrawn
|
|
1152
|
+
* whenever the default changes.
|
|
1153
|
+
*
|
|
940
1154
|
* @param {HTMLElement} container
|
|
1155
|
+
* @param {object} [ctx] - the section context
|
|
941
1156
|
* @returns {void}
|
|
942
1157
|
*/
|
|
943
|
-
function renderMailboxSection(container) {
|
|
944
|
-
|
|
1158
|
+
function renderMailboxSection(container, ctx = null) {
|
|
1159
|
+
let body = mailboxSectionBody(redraw);
|
|
1160
|
+
container.appendChild(body);
|
|
1161
|
+
|
|
1162
|
+
/** Rebuild the rows, keeping focus on the same mailbox where it can. */
|
|
1163
|
+
function redraw() {
|
|
1164
|
+
const focused = document.activeElement?.closest?.('.mail-box-row')?.dataset.id ?? null;
|
|
1165
|
+
const next = mailboxSectionBody(redraw);
|
|
1166
|
+
body.replaceWith(next);
|
|
1167
|
+
body = next;
|
|
1168
|
+
Domma.icons.scan(body);
|
|
1169
|
+
if (focused) body.querySelector(`.mail-box-row[data-id="${CSS.escape(focused)}"]`)?.focus();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const rowOf = (el) => {
|
|
1173
|
+
const row = el?.closest?.('.mail-box-row');
|
|
1174
|
+
return row && container.contains(row) ? row : null;
|
|
1175
|
+
};
|
|
1176
|
+
const boxOf = (row) => mailboxes.find(b => b.id === row?.dataset.id) ?? null;
|
|
1177
|
+
|
|
1178
|
+
const elements = (typeof E !== 'undefined' && E?.contextMenu) ? E
|
|
1179
|
+
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
1180
|
+
let menu = null;
|
|
1181
|
+
if (typeof elements?.contextMenu === 'function') {
|
|
1182
|
+
menu = elements.contextMenu(container, {
|
|
1183
|
+
match: '.mail-box-row',
|
|
1184
|
+
inherit: false,
|
|
1185
|
+
items: [],
|
|
1186
|
+
onBeforeOpen: (c) => {
|
|
1187
|
+
const box = boxOf(rowOf(c?.target));
|
|
1188
|
+
if (!box) return false;
|
|
1189
|
+
menu.setItems(mailboxRowItems(box, redraw));
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
const onKey = (event) => {
|
|
1195
|
+
const row = rowOf(event.target);
|
|
1196
|
+
if (!row) return;
|
|
1197
|
+
if (event.key === 'F10' && event.shiftKey) {
|
|
1198
|
+
event.preventDefault();
|
|
1199
|
+
openMenuAt(row.querySelector('.mail-row-more') ?? row);
|
|
1200
|
+
} else if (event.key === 'Enter' && event.target === row) {
|
|
1201
|
+
event.preventDefault();
|
|
1202
|
+
const box = boxOf(row);
|
|
1203
|
+
if (box) openMailboxForm(box);
|
|
1204
|
+
} else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && event.target === row) {
|
|
1205
|
+
const rows = [...container.querySelectorAll('.mail-box-row')];
|
|
1206
|
+
const next = rows[rows.indexOf(row) + (event.key === 'ArrowDown' ? 1 : -1)];
|
|
1207
|
+
if (next) { event.preventDefault(); next.focus(); }
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
container.addEventListener('keydown', onKey);
|
|
1211
|
+
// Saved, removed or connected elsewhere (the form, the folder
|
|
1212
|
+
// pane's menu): the rows follow.
|
|
1213
|
+
mailboxRedraws.add(redraw);
|
|
1214
|
+
ctx?.onClose?.(() => {
|
|
1215
|
+
mailboxRedraws.delete(redraw);
|
|
1216
|
+
container.removeEventListener('keydown', onKey);
|
|
1217
|
+
try { menu?.destroy?.(); } catch { /* gone */ }
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Mailbox sections on screen, redrawn when the list changes. */
|
|
1222
|
+
const mailboxRedraws = new Set();
|
|
1223
|
+
/** The mailbox list changed: redraw whichever Settings rows are showing. */
|
|
1224
|
+
function mailboxesChanged() {
|
|
1225
|
+
for (const redraw of [...mailboxRedraws]) { try { redraw(); } catch { /* gone */ } }
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* One mailbox row's menu.
|
|
1230
|
+
*
|
|
1231
|
+
* @param {object} box
|
|
1232
|
+
* @param {Function} redraw
|
|
1233
|
+
* @returns {object[]}
|
|
1234
|
+
*/
|
|
1235
|
+
function mailboxRowItems(box, redraw) {
|
|
1236
|
+
return menuItems(mailboxRowMenuModel({isDefault: box.isDefault === true, count: mailboxes.length}), {
|
|
1237
|
+
edit: () => openMailboxForm(box),
|
|
1238
|
+
open: () => openMailboxInReader(box),
|
|
1239
|
+
default: () => toggleDefaultMailbox(box, redraw),
|
|
1240
|
+
remove: () => removeMailbox(box, redraw)
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Show a mailbox in the reader, closing Settings to do it.
|
|
1246
|
+
*
|
|
1247
|
+
* @param {object} box
|
|
1248
|
+
* @returns {Promise<void>}
|
|
1249
|
+
*/
|
|
1250
|
+
async function openMailboxInReader(box) {
|
|
1251
|
+
for (const panel of [...openPanels]) { try { await panel.close(); } catch { /* gone */ } }
|
|
1252
|
+
if (!here()) return;
|
|
1253
|
+
if (box.id !== currentAccountId) await selectAccount(box.id);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Make a mailbox the default, or stop it being one.
|
|
1258
|
+
*
|
|
1259
|
+
* @param {object} box
|
|
1260
|
+
* @param {Function} [redraw]
|
|
1261
|
+
* @returns {Promise<void>}
|
|
1262
|
+
*/
|
|
1263
|
+
async function toggleDefaultMailbox(box, redraw = () => {}) {
|
|
1264
|
+
try {
|
|
1265
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}/default`, 'PUT',
|
|
1266
|
+
{isDefault: !box.isDefault});
|
|
1267
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1268
|
+
if (!here()) return;
|
|
1269
|
+
mailboxes = list;
|
|
1270
|
+
renderFolders();
|
|
1271
|
+
redraw();
|
|
1272
|
+
mailboxesChanged();
|
|
1273
|
+
E.toast(box.isDefault ? 'Default cleared.' : `"${box.label}" is now the default.`,
|
|
1274
|
+
{type: 'success'});
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
if (here()) E.toast(err.message, {type: 'error'});
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Forget a mailbox, after asking.
|
|
1282
|
+
*
|
|
1283
|
+
* @param {object} box
|
|
1284
|
+
* @param {Function} [redraw]
|
|
1285
|
+
* @returns {Promise<void>}
|
|
1286
|
+
*/
|
|
1287
|
+
async function removeMailbox(box, redraw = () => {}) {
|
|
1288
|
+
// The label is the person's own text, and E.confirm renders HTML.
|
|
1289
|
+
const ok = await ask(esc(`Remove "${box.label}"? The stored password is deleted, and so is anything unsaved in its form.`),
|
|
1290
|
+
{title: 'Remove mailbox', confirmText: 'Remove', cancelText: 'Keep it'});
|
|
1291
|
+
if (!ok || !here()) return;
|
|
1292
|
+
try {
|
|
1293
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'DELETE');
|
|
1294
|
+
writeMailboxDraft(box.id, null);
|
|
1295
|
+
E.toast('Mailbox removed.', {type: 'success'});
|
|
1296
|
+
refreshBadge();
|
|
1297
|
+
if (currentAccountId === box.id) currentAccountId = null;
|
|
1298
|
+
await start();
|
|
1299
|
+
if (here()) { redraw(); mailboxesChanged(); }
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
if (here()) E.toast(err.message, {type: 'error'});
|
|
1302
|
+
}
|
|
945
1303
|
}
|
|
946
1304
|
|
|
947
1305
|
/**
|
|
948
1306
|
* The mailbox rows and the Add button, rebuilt whole when the
|
|
949
|
-
* default changes so the order matches the folder pane.
|
|
1307
|
+
* default changes so the order matches the folder pane. Each row is a
|
|
1308
|
+
* card a keyboard can reach, with its own ⋮.
|
|
950
1309
|
*
|
|
1310
|
+
* @param {Function} redraw
|
|
951
1311
|
* @returns {HTMLElement}
|
|
952
1312
|
*/
|
|
953
|
-
function mailboxSectionBody() {
|
|
1313
|
+
function mailboxSectionBody(redraw) {
|
|
954
1314
|
const body = document.createElement('div');
|
|
955
1315
|
body.className = 'flex flex-col gap-2';
|
|
956
1316
|
|
|
957
1317
|
if (!mailboxes.length) {
|
|
958
1318
|
const empty = document.createElement('p');
|
|
959
1319
|
empty.className = 'text-sm text-muted mail-empty-line';
|
|
960
|
-
empty.textContent = 'No mailboxes yet.';
|
|
1320
|
+
empty.textContent = 'No mailboxes yet. Add one to read its mail here.';
|
|
961
1321
|
body.appendChild(empty);
|
|
962
1322
|
}
|
|
963
1323
|
|
|
964
1324
|
for (const box of mailboxes) {
|
|
965
1325
|
const row = document.createElement('div');
|
|
966
1326
|
row.className = 'mail-box-row';
|
|
1327
|
+
row.dataset.id = box.id;
|
|
1328
|
+
row.tabIndex = 0;
|
|
1329
|
+
row.setAttribute('aria-label', `${box.label} - Enter to edit, Shift+F10 for actions`);
|
|
1330
|
+
row.title = 'Right-click, ⋮ or Shift+F10 for actions';
|
|
967
1331
|
|
|
968
1332
|
const info = document.createElement('div');
|
|
969
1333
|
info.className = 'flex-1 mail-ellipsis';
|
|
@@ -976,12 +1340,18 @@ return {
|
|
|
976
1340
|
badge.textContent = 'Default';
|
|
977
1341
|
name.appendChild(badge);
|
|
978
1342
|
}
|
|
1343
|
+
if (box.id === currentAccountId && mailboxes.length > 1) {
|
|
1344
|
+
const open = document.createElement('span');
|
|
1345
|
+
open.className = 'badge badge-secondary mail-default-badge';
|
|
1346
|
+
open.textContent = 'Open';
|
|
1347
|
+
name.appendChild(open);
|
|
1348
|
+
}
|
|
979
1349
|
const sub = document.createElement('div');
|
|
980
1350
|
sub.className = 'text-xs text-muted mail-box-sub';
|
|
981
1351
|
const signIn = describeSignIn?.(box) ?? null;
|
|
982
1352
|
sub.textContent = signIn
|
|
983
|
-
? `${box.username}
|
|
984
|
-
: `${box.username}
|
|
1353
|
+
? `${box.username} · ${box.oauth.provider === 'google' ? 'Google' : 'Microsoft'} sign-in`
|
|
1354
|
+
: `${box.username} · ${box.host}:${box.port}${box.secure ? '' : ' (no TLS)'}`;
|
|
985
1355
|
if (signIn && signIn.tone !== 'ok') {
|
|
986
1356
|
const chip = document.createElement('span');
|
|
987
1357
|
chip.className = `badge mail-oauth-chip mail-oauth-chip--${signIn.tone}`;
|
|
@@ -989,78 +1359,187 @@ return {
|
|
|
989
1359
|
chip.title = signIn.text;
|
|
990
1360
|
name.appendChild(chip);
|
|
991
1361
|
}
|
|
1362
|
+
if (readMailboxDraft(box.id)) {
|
|
1363
|
+
const chip = document.createElement('span');
|
|
1364
|
+
chip.className = 'badge badge-warning mail-default-badge';
|
|
1365
|
+
chip.textContent = 'Unsaved changes';
|
|
1366
|
+
chip.title = 'You left changes to this mailbox unsaved - Edit to pick them up or throw them away';
|
|
1367
|
+
name.appendChild(chip);
|
|
1368
|
+
}
|
|
992
1369
|
info.appendChild(name);
|
|
993
1370
|
info.appendChild(sub);
|
|
994
1371
|
row.appendChild(info);
|
|
995
1372
|
|
|
996
1373
|
const editBtn = document.createElement('button');
|
|
1374
|
+
editBtn.type = 'button';
|
|
997
1375
|
editBtn.className = 'btn btn-sm btn-ghost';
|
|
998
1376
|
editBtn.textContent = 'Edit';
|
|
999
1377
|
editBtn.addEventListener('click', () => openMailboxForm(box));
|
|
1378
|
+
row.appendChild(editBtn);
|
|
1000
1379
|
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
} catch (err) {
|
|
1014
|
-
E.toast(err.message, {type: 'error'});
|
|
1015
|
-
}
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
// Only worth offering when there is a choice to make.
|
|
1019
|
-
if (mailboxes.length > 1) {
|
|
1020
|
-
const defaultBtn = document.createElement('button');
|
|
1021
|
-
defaultBtn.className = 'btn btn-sm btn-ghost';
|
|
1022
|
-
defaultBtn.textContent = box.isDefault ? 'Unset default' : 'Make default';
|
|
1023
|
-
defaultBtn.title = box.isDefault
|
|
1024
|
-
? 'Stop opening with this mailbox'
|
|
1025
|
-
: 'List this mailbox first and open with it';
|
|
1026
|
-
defaultBtn.addEventListener('click', async () => {
|
|
1027
|
-
try {
|
|
1028
|
-
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}/default`, 'PUT',
|
|
1029
|
-
{isDefault: !box.isDefault});
|
|
1030
|
-
mailboxes = (await api(`${BASE}/accounts`)).accounts;
|
|
1031
|
-
renderFolders();
|
|
1032
|
-
body.replaceWith(mailboxSectionBody());
|
|
1033
|
-
E.toast(box.isDefault ? 'Default cleared.' : `"${box.label}" is now the default.`,
|
|
1034
|
-
{type: 'success'});
|
|
1035
|
-
} catch (err) {
|
|
1036
|
-
E.toast(err.message, {type: 'error'});
|
|
1037
|
-
}
|
|
1038
|
-
});
|
|
1039
|
-
row.appendChild(defaultBtn);
|
|
1040
|
-
}
|
|
1380
|
+
const more = document.createElement('button');
|
|
1381
|
+
more.type = 'button';
|
|
1382
|
+
more.className = 'btn btn-sm btn-ghost mail-row-more';
|
|
1383
|
+
more.title = 'More actions';
|
|
1384
|
+
more.setAttribute('aria-label', 'More actions');
|
|
1385
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
1386
|
+
const glyph = document.createElement('span');
|
|
1387
|
+
glyph.setAttribute('data-icon', 'more-vertical');
|
|
1388
|
+
glyph.setAttribute('data-icon-size', '14');
|
|
1389
|
+
more.appendChild(glyph);
|
|
1390
|
+
more.addEventListener('click', () => openMenuAt(more));
|
|
1391
|
+
row.appendChild(more);
|
|
1041
1392
|
|
|
1042
|
-
row.appendChild(editBtn);
|
|
1043
|
-
row.appendChild(removeBtn);
|
|
1044
1393
|
body.appendChild(row);
|
|
1045
1394
|
}
|
|
1046
1395
|
|
|
1047
1396
|
const addBtn = document.createElement('button');
|
|
1397
|
+
addBtn.type = 'button';
|
|
1048
1398
|
addBtn.className = 'btn btn-sm btn-primary';
|
|
1049
1399
|
addBtn.classList.add('mail-add-btn');
|
|
1050
|
-
addBtn.textContent = 'Add mailbox';
|
|
1400
|
+
addBtn.textContent = readMailboxDraft(null) ? 'Add mailbox (unsaved changes)' : 'Add mailbox';
|
|
1051
1401
|
addBtn.addEventListener('click', () => openMailboxForm(null));
|
|
1052
1402
|
body.appendChild(addBtn);
|
|
1403
|
+
Domma.icons.scan(body);
|
|
1053
1404
|
|
|
1054
1405
|
return body;
|
|
1055
1406
|
}
|
|
1056
1407
|
|
|
1408
|
+
// ---- The mailbox form's kept draft --------------------------------
|
|
1409
|
+
//
|
|
1410
|
+
// Label, server, port, TLS and username - NEVER the password, which
|
|
1411
|
+
// is the one field a draft in browser storage must not hold. Kept
|
|
1412
|
+
// while the form differs from what it opened with, removed when it is
|
|
1413
|
+
// saved or the person agrees to throw it away, and KEPT when the
|
|
1414
|
+
// screen is left with the form open.
|
|
1415
|
+
|
|
1057
1416
|
/**
|
|
1058
|
-
*
|
|
1417
|
+
* This signed-in user's drafts key - per user, since browser storage
|
|
1418
|
+
* is shared by whoever signs in here. Null (no drafts) without one.
|
|
1419
|
+
*
|
|
1420
|
+
* @returns {string|null}
|
|
1421
|
+
*/
|
|
1422
|
+
function draftsKey() {
|
|
1423
|
+
try { return mailboxDraftsKey(S.get('auth_user')?.id); } catch { return null; }
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** @returns {Object.<string, object>} */
|
|
1427
|
+
function readMailboxDrafts() {
|
|
1428
|
+
const key = draftsKey();
|
|
1429
|
+
if (!key) return {};
|
|
1430
|
+
try { return normaliseDraftMap(S.get(key)); } catch { return {}; }
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* @param {string|null} boxId
|
|
1435
|
+
* @returns {object|null}
|
|
1436
|
+
*/
|
|
1437
|
+
function readMailboxDraft(boxId) {
|
|
1438
|
+
return readMailboxDrafts()[mailboxDraftId(boxId)] ?? null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Write one draft (null removes it), read-modify-write on a fresh
|
|
1443
|
+
* copy of the map - nothing holds a copy that could be stale.
|
|
1444
|
+
*
|
|
1445
|
+
* @param {string|null} boxId
|
|
1446
|
+
* @param {object|null} draft
|
|
1447
|
+
* @returns {void}
|
|
1448
|
+
*/
|
|
1449
|
+
function writeMailboxDraft(boxId, draft) {
|
|
1450
|
+
try {
|
|
1451
|
+
const map = readMailboxDrafts();
|
|
1452
|
+
const id = mailboxDraftId(boxId);
|
|
1453
|
+
if (draft) map[id] = draft;
|
|
1454
|
+
else delete map[id];
|
|
1455
|
+
const key = draftsKey();
|
|
1456
|
+
if (!key) return;
|
|
1457
|
+
if (Object.keys(map).length) S.set(key, map);
|
|
1458
|
+
else forgetDrafts();
|
|
1459
|
+
} catch { /* a convenience: the form still works */ }
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** No drafts left: take the key out rather than keep an empty map. */
|
|
1463
|
+
function forgetDrafts() {
|
|
1464
|
+
const key = draftsKey();
|
|
1465
|
+
if (!key) return;
|
|
1466
|
+
if (typeof S.remove === 'function') S.remove(key);
|
|
1467
|
+
else S.set(key, {});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Drop the drafts of mailboxes that no longer exist. */
|
|
1471
|
+
function pruneMailboxDrafts() {
|
|
1472
|
+
try {
|
|
1473
|
+
const map = readMailboxDrafts();
|
|
1474
|
+
const kept = pruneDraftMap(map, mailboxes.map(b => b.id));
|
|
1475
|
+
if (Object.keys(kept).length !== Object.keys(map).length) {
|
|
1476
|
+
if (Object.keys(kept).length) S.set(draftsKey(), kept);
|
|
1477
|
+
else forgetDrafts();
|
|
1478
|
+
}
|
|
1479
|
+
} catch { /* ditto */ }
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Open the add/edit form for one mailbox - once at a time, after
|
|
1484
|
+
* asking about any changes left unsaved last time.
|
|
1485
|
+
*
|
|
1486
|
+
* @param {object|null} box - null to add
|
|
1487
|
+
* @returns {Promise<void>}
|
|
1488
|
+
*/
|
|
1489
|
+
async function openMailboxForm(box) {
|
|
1490
|
+
if (!mailboxFormOnce.claim()) return;
|
|
1491
|
+
let opened = false;
|
|
1492
|
+
try {
|
|
1493
|
+
// Edit the stored version, not the row's copy from whenever the
|
|
1494
|
+
// list was drawn - and judge a kept draft against it.
|
|
1495
|
+
if (box) {
|
|
1496
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1497
|
+
if (!here()) return;
|
|
1498
|
+
mailboxes = list;
|
|
1499
|
+
const fresh = list.find(b => b.id === box.id);
|
|
1500
|
+
if (!fresh) {
|
|
1501
|
+
// Removed since (another tab, another device). Its
|
|
1502
|
+
// unsaved changes have nowhere to go.
|
|
1503
|
+
writeMailboxDraft(box.id, null);
|
|
1504
|
+
E.toast(`"${box.label}" has been removed since, so it cannot be edited.`, {type: 'warning'});
|
|
1505
|
+
renderFolders();
|
|
1506
|
+
mailboxesChanged();
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
box = fresh;
|
|
1510
|
+
}
|
|
1511
|
+
const kept = readMailboxDraft(box?.id ?? null);
|
|
1512
|
+
const decision = mailboxDraftDecision(kept, box);
|
|
1513
|
+
if (decision.kind === 'drop') writeMailboxDraft(box?.id ?? null, null);
|
|
1514
|
+
let picked = null;
|
|
1515
|
+
if (decision.kind === 'ask') {
|
|
1516
|
+
// OK throws the changes away; Cancel, Escape and the
|
|
1517
|
+
// backdrop all keep them. The draft is removed only once
|
|
1518
|
+
// answered - leaving while asked keeps it for next time.
|
|
1519
|
+
const throwAway = await ask(esc(mailboxRestoreQuestion({
|
|
1520
|
+
label: box?.label, at: kept.at, changed: decision.changed, isNew: !box
|
|
1521
|
+
})), RESTORE_BUTTONS);
|
|
1522
|
+
if (!here()) return;
|
|
1523
|
+
if (throwAway) writeMailboxDraft(box?.id ?? null, null);
|
|
1524
|
+
else picked = kept;
|
|
1525
|
+
}
|
|
1526
|
+
buildMailboxForm(box, picked);
|
|
1527
|
+
opened = true;
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
if (here()) E.toast(err.message || 'That form could not be opened.', {type: 'error'});
|
|
1530
|
+
} finally {
|
|
1531
|
+
if (!opened) mailboxFormOnce.release();
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Draw the add/edit form.
|
|
1059
1537
|
*
|
|
1060
1538
|
* @param {object|null} box - null to add
|
|
1539
|
+
* @param {{data: object, was: string}|null} restored - a kept draft picked up
|
|
1061
1540
|
* @returns {void}
|
|
1062
1541
|
*/
|
|
1063
|
-
function
|
|
1542
|
+
function buildMailboxForm(box, restored) {
|
|
1064
1543
|
const slideover = E.slideover({
|
|
1065
1544
|
title: box ? 'Edit Mailbox' : 'Add Mailbox',
|
|
1066
1545
|
size: 'md',
|
|
@@ -1070,6 +1549,9 @@ return {
|
|
|
1070
1549
|
storageKey: 'domma-mail.mailbox-form-width',
|
|
1071
1550
|
defaultWidth: 560
|
|
1072
1551
|
});
|
|
1552
|
+
const draftFor = box?.id ?? null;
|
|
1553
|
+
// NOT `start` - that is the view's own reload, called below.
|
|
1554
|
+
const initial = restored?.data ?? mailboxDraftData(box ?? {});
|
|
1073
1555
|
|
|
1074
1556
|
const form = document.createElement('div');
|
|
1075
1557
|
form.className = 'mail-form-stack';
|
|
@@ -1080,10 +1562,17 @@ return {
|
|
|
1080
1562
|
form.appendChild(pwWrap);
|
|
1081
1563
|
const signedIn = box?.auth === 'oauth';
|
|
1082
1564
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1565
|
+
if (restored) {
|
|
1566
|
+
const note = document.createElement('p');
|
|
1567
|
+
note.className = 'text-sm text-muted mail-restored-note';
|
|
1568
|
+
note.textContent = 'These are the changes you left unsaved. The password is never kept - type it again if you changed it.';
|
|
1569
|
+
pwWrap.appendChild(note);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const labelInput = field(pwWrap, 'label', 'Name', 'text', box || restored ? initial.label : '', 'What to call this mailbox.');
|
|
1573
|
+
const hostInput = field(pwWrap, 'host', 'IMAP server', 'text', box || restored ? initial.host : '', 'e.g. mail.example.com');
|
|
1574
|
+
const portInput = field(pwWrap, 'port', 'Port', 'number', String(box || restored ? initial.port : 993));
|
|
1575
|
+
const userInput = field(pwWrap, 'username', 'Username', 'text', box || restored ? initial.username : '');
|
|
1087
1576
|
const passInput = field(
|
|
1088
1577
|
pwWrap, 'password', 'Password', 'password', '',
|
|
1089
1578
|
box?.hasPassword ? 'Leave blank to keep the saved password.' : ''
|
|
@@ -1093,14 +1582,18 @@ return {
|
|
|
1093
1582
|
secureWrap.className = 'flex items-center gap-2 text-sm';
|
|
1094
1583
|
const secureInput = document.createElement('input');
|
|
1095
1584
|
secureInput.type = 'checkbox';
|
|
1096
|
-
secureInput.checked = box ?
|
|
1585
|
+
secureInput.checked = box || restored ? initial.secure !== false : true;
|
|
1097
1586
|
secureWrap.appendChild(secureInput);
|
|
1098
1587
|
secureWrap.appendChild(document.createTextNode('Use TLS (port 993)'));
|
|
1588
|
+
const tlsHelp = document.createElement('span');
|
|
1589
|
+
tlsHelp.setAttribute('data-help-title', 'TLS');
|
|
1590
|
+
tlsHelp.setAttribute('data-help', 'Encrypts the connection to the mail server, so the password and your mail cannot be read on the way. Almost every server wants it on, on port 993. Turn it off only for a server that offers plain IMAP on port 143.');
|
|
1591
|
+
secureWrap.appendChild(tlsHelp);
|
|
1099
1592
|
pwWrap.appendChild(secureWrap);
|
|
1100
1593
|
|
|
1101
1594
|
// Keep the port in step with the TLS choice until the user sets one
|
|
1102
1595
|
// themselves - 993/143 is the only pair that matters here.
|
|
1103
|
-
let portTouched =
|
|
1596
|
+
let portTouched = Boolean(restored);
|
|
1104
1597
|
portInput.addEventListener('input', () => {
|
|
1105
1598
|
portTouched = true;
|
|
1106
1599
|
});
|
|
@@ -1112,7 +1605,7 @@ return {
|
|
|
1112
1605
|
status.className = 'text-sm mail-status';
|
|
1113
1606
|
|
|
1114
1607
|
/**
|
|
1115
|
-
* Say something under the
|
|
1608
|
+
* Say something under the form.
|
|
1116
1609
|
*
|
|
1117
1610
|
* A class rather than `style.color`, for the reason given
|
|
1118
1611
|
* throughout this file: an inline colour cannot read a `--dm-*`
|
|
@@ -1134,15 +1627,18 @@ return {
|
|
|
1134
1627
|
actions.className = 'flex justify-between gap-2 mt-2';
|
|
1135
1628
|
|
|
1136
1629
|
const testBtn = document.createElement('button');
|
|
1630
|
+
testBtn.type = 'button';
|
|
1137
1631
|
testBtn.className = 'btn btn-sm btn-ghost';
|
|
1138
1632
|
testBtn.textContent = 'Test connection';
|
|
1139
1633
|
|
|
1140
1634
|
const right = document.createElement('div');
|
|
1141
1635
|
right.className = 'flex gap-2';
|
|
1142
1636
|
const cancelBtn = document.createElement('button');
|
|
1637
|
+
cancelBtn.type = 'button';
|
|
1143
1638
|
cancelBtn.className = 'btn btn-sm btn-ghost';
|
|
1144
1639
|
cancelBtn.textContent = 'Cancel';
|
|
1145
1640
|
const saveBtn = document.createElement('button');
|
|
1641
|
+
saveBtn.type = 'button';
|
|
1146
1642
|
saveBtn.className = 'btn btn-sm btn-primary';
|
|
1147
1643
|
saveBtn.textContent = 'Save';
|
|
1148
1644
|
right.appendChild(cancelBtn);
|
|
@@ -1153,20 +1649,54 @@ return {
|
|
|
1153
1649
|
pwWrap.appendChild(actions);
|
|
1154
1650
|
|
|
1155
1651
|
slideover.element.appendChild(form);
|
|
1156
|
-
// What
|
|
1157
|
-
|
|
1652
|
+
// What "unchanged" means: the stored mailbox (or a blank new
|
|
1653
|
+
// one) - NOT a restored draft, which is unsaved by definition.
|
|
1654
|
+
const clean = JSON.stringify(mailboxDraftData(box ?? {}));
|
|
1655
|
+
// `was` - what the mailbox looked like when this editing began. A
|
|
1656
|
+
// picked-up draft keeps its own, so the next question still knows
|
|
1657
|
+
// the mailbox moved under it.
|
|
1658
|
+
const was = restored ? restored.was : mailboxFingerprint(box);
|
|
1659
|
+
const current = () => JSON.stringify(mailboxDraftData(collect()));
|
|
1158
1660
|
let signInPanel = null;
|
|
1159
1661
|
let stopChoiceHelp = null;
|
|
1662
|
+
let stopFormHelp = null;
|
|
1160
1663
|
let method = signedIn ? 'oauth' : 'password';
|
|
1664
|
+
let saving = false;
|
|
1665
|
+
let discarded = false;
|
|
1666
|
+
const formDirty = () => current() !== clean;
|
|
1667
|
+
|
|
1668
|
+
/** Keep the draft in step with the form: written while it differs, gone when it does not. */
|
|
1669
|
+
const touch = () => {
|
|
1670
|
+
if (discarded || saving || method !== 'password' || !here()) return;
|
|
1671
|
+
if (formDirty()) writeMailboxDraft(draftFor, {data: mailboxDraftData(collect()), was, at: Date.now()});
|
|
1672
|
+
else writeMailboxDraft(draftFor, null);
|
|
1673
|
+
};
|
|
1674
|
+
const onEdit = () => setTimeout(touch, 0);
|
|
1675
|
+
for (const type of ['input', 'change']) pwWrap.addEventListener(type, onEdit);
|
|
1676
|
+
|
|
1161
1677
|
guardPanel(slideover,
|
|
1162
|
-
() => (method === 'password'
|
|
1163
|
-
? passInput.value !== '' ||
|
|
1678
|
+
() => !saving && (method === 'password'
|
|
1679
|
+
? passInput.value !== '' || formDirty()
|
|
1164
1680
|
: Boolean(signInPanel?.dirty())),
|
|
1165
1681
|
box ? 'Discard your changes to this mailbox?' : 'Discard this mailbox? It has not been saved.',
|
|
1166
|
-
() => {
|
|
1682
|
+
() => {
|
|
1683
|
+
mailboxFormOnce.release();
|
|
1684
|
+
stopFormResize();
|
|
1685
|
+
signInPanel?.dispose();
|
|
1686
|
+
stopChoiceHelp?.();
|
|
1687
|
+
stopFormHelp?.();
|
|
1688
|
+
for (const type of ['input', 'change']) pwWrap.removeEventListener(type, onEdit);
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
// Agreed to lose them: the draft goes too. A forced close
|
|
1692
|
+
// (the screen being left) never gets here, so it keeps it.
|
|
1693
|
+
onDiscard: () => { discarded = true; writeMailboxDraft(draftFor, null); }
|
|
1694
|
+
});
|
|
1167
1695
|
slideover.open();
|
|
1696
|
+
stopFormHelp = helpPopovers(pwWrap, {position: 'bottom'});
|
|
1697
|
+
if (restored) touch();
|
|
1168
1698
|
if (signedIn) pwWrap.hidden = true;
|
|
1169
|
-
else setTimeout(() => hostInput.focus(), 50);
|
|
1699
|
+
else setTimeout(() => (box ? labelInput : hostInput).focus(), 50);
|
|
1170
1700
|
|
|
1171
1701
|
/**
|
|
1172
1702
|
* Offer "Sign in with Microsoft / Google" when this edition has it
|
|
@@ -1177,6 +1707,7 @@ return {
|
|
|
1177
1707
|
const oauth = features.oauth && mountSignIn
|
|
1178
1708
|
? await api(`${BASE}/oauth/status`).catch(() => null)
|
|
1179
1709
|
: null;
|
|
1710
|
+
if (!here() || !slideover.element?.isConnected) return;
|
|
1180
1711
|
if (!oauth?.available) {
|
|
1181
1712
|
if (signedIn) {
|
|
1182
1713
|
const note = document.createElement('p');
|
|
@@ -1203,14 +1734,23 @@ return {
|
|
|
1203
1734
|
signInPanel = mountSignIn(signInHost, {
|
|
1204
1735
|
provider, box, base: BASE, api, status: oauth, helpPopovers,
|
|
1205
1736
|
onConnected: async (accountId) => {
|
|
1737
|
+
discarded = true;
|
|
1738
|
+
writeMailboxDraft(draftFor, null);
|
|
1206
1739
|
slideover.close(true);
|
|
1207
1740
|
E.toast('Mailbox connected.', {type: 'success'});
|
|
1741
|
+
refreshBadge();
|
|
1742
|
+
if (!here()) return;
|
|
1208
1743
|
if (accountId) currentAccountId = accountId;
|
|
1209
1744
|
await start();
|
|
1745
|
+
if (here()) mailboxesChanged();
|
|
1210
1746
|
},
|
|
1211
1747
|
onChanged: async () => {
|
|
1212
|
-
|
|
1748
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1749
|
+
if (!here()) return;
|
|
1750
|
+
mailboxes = list;
|
|
1213
1751
|
renderFolders();
|
|
1752
|
+
mailboxesChanged();
|
|
1753
|
+
refreshBadge();
|
|
1214
1754
|
},
|
|
1215
1755
|
onCancel: () => slideover.close()
|
|
1216
1756
|
});
|
|
@@ -1275,9 +1815,10 @@ return {
|
|
|
1275
1815
|
|
|
1276
1816
|
testBtn.addEventListener('click', async () => {
|
|
1277
1817
|
testBtn.disabled = true;
|
|
1278
|
-
say('Connecting
|
|
1818
|
+
say('Connecting…');
|
|
1279
1819
|
try {
|
|
1280
1820
|
const result = await api(`${BASE}/accounts/test`, 'POST', collect());
|
|
1821
|
+
if (!slideover.element?.isConnected) return;
|
|
1281
1822
|
say(
|
|
1282
1823
|
result.ok ? 'Connected successfully.' : result.error,
|
|
1283
1824
|
result.ok ? 'ok' : 'bad'
|
|
@@ -1292,19 +1833,36 @@ return {
|
|
|
1292
1833
|
cancelBtn.addEventListener('click', () => slideover.close());
|
|
1293
1834
|
|
|
1294
1835
|
saveBtn.addEventListener('click', async () => {
|
|
1836
|
+
if (saving) return;
|
|
1837
|
+
// The panel closes once saved, so nothing typed while the
|
|
1838
|
+
// request runs could be kept: the form is locked until it
|
|
1839
|
+
// answers, and unlocked again if it fails.
|
|
1840
|
+
saving = true;
|
|
1295
1841
|
saveBtn.disabled = true;
|
|
1842
|
+
form.inert = true;
|
|
1843
|
+
form.setAttribute('aria-busy', 'true');
|
|
1844
|
+
say('Saving…');
|
|
1296
1845
|
try {
|
|
1297
1846
|
const result = box
|
|
1298
1847
|
? await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'PUT', collect())
|
|
1299
1848
|
: await api(`${BASE}/accounts`, 'POST', collect());
|
|
1849
|
+
discarded = true;
|
|
1850
|
+
writeMailboxDraft(draftFor, null);
|
|
1300
1851
|
slideover.close(true);
|
|
1301
1852
|
E.toast('Mailbox saved.', {type: 'success'});
|
|
1853
|
+
refreshBadge();
|
|
1854
|
+
if (!here()) return;
|
|
1302
1855
|
currentAccountId = result.account.id;
|
|
1303
1856
|
await start();
|
|
1857
|
+
if (here()) mailboxesChanged();
|
|
1304
1858
|
} catch (err) {
|
|
1305
1859
|
say(err.message, 'bad');
|
|
1306
|
-
|
|
1860
|
+
saving = false;
|
|
1307
1861
|
saveBtn.disabled = false;
|
|
1862
|
+
form.inert = false;
|
|
1863
|
+
form.removeAttribute('aria-busy');
|
|
1864
|
+
// Kept for next time, as it stood when Save was pressed.
|
|
1865
|
+
touch();
|
|
1308
1866
|
}
|
|
1309
1867
|
});
|
|
1310
1868
|
}
|
|
@@ -1320,6 +1878,7 @@ return {
|
|
|
1320
1878
|
*/
|
|
1321
1879
|
function renderFolders() {
|
|
1322
1880
|
foldersEl.textContent = '';
|
|
1881
|
+
paintUnreadFilter();
|
|
1323
1882
|
|
|
1324
1883
|
for (const box of mailboxes) {
|
|
1325
1884
|
const isCurrent = box.id === currentAccountId;
|
|
@@ -1340,8 +1899,19 @@ return {
|
|
|
1340
1899
|
name.textContent = box.label;
|
|
1341
1900
|
name.title = `${box.username} · ${box.host}`;
|
|
1342
1901
|
accountBtn.appendChild(name);
|
|
1343
|
-
|
|
1344
|
-
accountBtn.
|
|
1902
|
+
accountBtn.dataset.account = box.id;
|
|
1903
|
+
accountBtn.title = `${box.username} \u00b7 click to open \u00b7 right-click, \u22ee or Shift+F10 for actions`;
|
|
1904
|
+
accountBtn.appendChild(rowMore());
|
|
1905
|
+
|
|
1906
|
+
accountBtn.addEventListener('click', (event) => {
|
|
1907
|
+
const more = event.target.closest('.mail-row-more');
|
|
1908
|
+
if (more) {
|
|
1909
|
+
event.stopPropagation();
|
|
1910
|
+
openMenuAt(more);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
selectAccount(box.id);
|
|
1914
|
+
});
|
|
1345
1915
|
foldersEl.appendChild(accountBtn);
|
|
1346
1916
|
}
|
|
1347
1917
|
|
|
@@ -1398,8 +1968,8 @@ return {
|
|
|
1398
1968
|
// the same leaf name apart - and doubles as the hint.
|
|
1399
1969
|
name.title = folder.path;
|
|
1400
1970
|
btn.title = features.folders
|
|
1401
|
-
? `${folder.path} - right-click to rename, delete or add a folder inside`
|
|
1402
|
-
: folder.path
|
|
1971
|
+
? `${folder.path} - right-click, \u22ee or Shift+F10 to rename, delete or add a folder inside`
|
|
1972
|
+
: `${folder.path} - right-click, \u22ee or Shift+F10 for more`;
|
|
1403
1973
|
btn.appendChild(name);
|
|
1404
1974
|
|
|
1405
1975
|
if (folder.contextOnly) btn.classList.add('is-context');
|
|
@@ -1420,6 +1990,7 @@ return {
|
|
|
1420
1990
|
: `${count} unread`;
|
|
1421
1991
|
btn.appendChild(pill);
|
|
1422
1992
|
}
|
|
1993
|
+
btn.appendChild(rowMore());
|
|
1423
1994
|
|
|
1424
1995
|
// One handler, two gestures: the chevron folds, the rest of
|
|
1425
1996
|
// the row opens the folder. A nested <button> inside a
|
|
@@ -1429,6 +2000,12 @@ return {
|
|
|
1429
2000
|
// click opens the folder, which redraws this pane, and a
|
|
1430
2001
|
// dblclick needs both clicks to land on the same element.
|
|
1431
2002
|
btn.addEventListener('click', event => {
|
|
2003
|
+
const more = event.target.closest('.mail-row-more');
|
|
2004
|
+
if (more) {
|
|
2005
|
+
event.stopPropagation();
|
|
2006
|
+
openMenuAt(more);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
1432
2009
|
const onToggle = !!event.target.closest('.mail-folder-toggle');
|
|
1433
2010
|
if (event.detail >= 2) {
|
|
1434
2011
|
// The first click already folded it when it hit
|
|
@@ -1458,6 +2035,39 @@ return {
|
|
|
1458
2035
|
Domma.icons.scan(foldersEl);
|
|
1459
2036
|
}
|
|
1460
2037
|
|
|
2038
|
+
/**
|
|
2039
|
+
* The Unread filter says how many it would show: the unread in this
|
|
2040
|
+
* mailbox's folders, counted from the listing already in hand.
|
|
2041
|
+
*
|
|
2042
|
+
* @returns {void}
|
|
2043
|
+
*/
|
|
2044
|
+
function paintUnreadFilter() {
|
|
2045
|
+
const label = unreadFilterEl?.querySelector('span');
|
|
2046
|
+
if (!label) return;
|
|
2047
|
+
const unread = folders.reduce((n, f) => n + (Number.isFinite(f.unseen) ? f.unseen : 0), 0);
|
|
2048
|
+
label.textContent = unread ? `Unread (${unread > 999 ? '999+' : unread})` : 'Unread';
|
|
2049
|
+
unreadFilterEl.title = unread
|
|
2050
|
+
? `Show only the folders holding the ${unread} unread message${unread === 1 ? '' : 's'}`
|
|
2051
|
+
: 'Show only folders with unread mail';
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* The ⋮ on a folder-pane row. A span with the button role, not a
|
|
2056
|
+
* <button>: the row IS a button, and the click target decides - the
|
|
2057
|
+
* same resolution as the message list's ⋮ and the fold chevron.
|
|
2058
|
+
*
|
|
2059
|
+
* @returns {HTMLElement}
|
|
2060
|
+
*/
|
|
2061
|
+
function rowMore() {
|
|
2062
|
+
const more = document.createElement('span');
|
|
2063
|
+
more.className = 'mail-row-more mail-folder-more';
|
|
2064
|
+
more.setAttribute('role', 'button');
|
|
2065
|
+
more.setAttribute('aria-label', 'Actions');
|
|
2066
|
+
more.title = 'Actions';
|
|
2067
|
+
more.textContent = '\u22ee';
|
|
2068
|
+
return more;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1461
2071
|
/**
|
|
1462
2072
|
* Remember which mailbox is being looked at.
|
|
1463
2073
|
*
|
|
@@ -1573,7 +2183,13 @@ return {
|
|
|
1573
2183
|
}
|
|
1574
2184
|
if (!here() || currentAccountId !== account) return;
|
|
1575
2185
|
page = 1;
|
|
1576
|
-
await loadMessages({fresh: true});
|
|
2186
|
+
await loadMessages({fresh: true, quiet: true});
|
|
2187
|
+
if (!here()) return;
|
|
2188
|
+
// A refresh is when the unread badge is most likely to have
|
|
2189
|
+
// moved - new mail, or mail read on the phone. The read routes
|
|
2190
|
+
// forget their cached badge on a fresh read, so this gets a
|
|
2191
|
+
// new number rather than the one from a minute ago.
|
|
2192
|
+
refreshBadge();
|
|
1577
2193
|
} catch (err) {
|
|
1578
2194
|
handleError(err);
|
|
1579
2195
|
} finally {
|
|
@@ -1581,12 +2197,60 @@ return {
|
|
|
1581
2197
|
}
|
|
1582
2198
|
}
|
|
1583
2199
|
|
|
2200
|
+
/**
|
|
2201
|
+
* Bumped by every list load, so an answer that lands after a newer
|
|
2202
|
+
* request was made (a quick second folder click) is dropped rather
|
|
2203
|
+
* than drawn over the newer one.
|
|
2204
|
+
*/
|
|
2205
|
+
let listSeq = 0;
|
|
2206
|
+
/** Is this load still the one that matters, on a screen still here? */
|
|
2207
|
+
const listCurrent = (seq) => here() && seq === listSeq;
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Run a list request. A NEW list (another folder, page or search)
|
|
2211
|
+
* empties the pane and shows a skeleton while it loads; a quiet one
|
|
2212
|
+
* (a refresh, the list after an undo or a send) keeps what is on
|
|
2213
|
+
* screen until the answer replaces it.
|
|
2214
|
+
*
|
|
2215
|
+
* @param {Function} work
|
|
2216
|
+
* @param {boolean} quiet
|
|
2217
|
+
* @returns {Promise<*>}
|
|
2218
|
+
*/
|
|
2219
|
+
function listLoad(work, quiet) {
|
|
2220
|
+
if (quiet) return work();
|
|
2221
|
+
disposeRowEffects();
|
|
2222
|
+
listEl.textContent = '';
|
|
2223
|
+
pagerEl.textContent = '';
|
|
2224
|
+
return withSkeleton(listEl, work, 'rows');
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* A list load failed. Said once, and only for the load that is still
|
|
2229
|
+
* current; a new list that could not be fetched says so in the pane
|
|
2230
|
+
* rather than leaving it blank.
|
|
2231
|
+
*
|
|
2232
|
+
* @param {Error} err
|
|
2233
|
+
* @param {number} seq
|
|
2234
|
+
* @param {boolean} quiet
|
|
2235
|
+
* @returns {void}
|
|
2236
|
+
*/
|
|
2237
|
+
function listFailed(err, seq, quiet) {
|
|
2238
|
+
if (!listCurrent(seq)) return;
|
|
2239
|
+
handleError(err);
|
|
2240
|
+
if (!quiet && !err.needsSetup && !listEl.childElementCount) {
|
|
2241
|
+
showEmpty(listEl, 'alert-triangle', 'This list could not be loaded.', {
|
|
2242
|
+
label: 'Try again',
|
|
2243
|
+
onClick: () => loadMessages()
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
1584
2248
|
/** Fetch and draw the current page. */
|
|
1585
|
-
async function loadMessages({fresh = false} = {}) {
|
|
1586
|
-
if (searchQuery) return runSearch();
|
|
1587
|
-
if (threadsOn) return loadThreads({fresh});
|
|
2249
|
+
async function loadMessages({fresh = false, quiet = false} = {}) {
|
|
2250
|
+
if (searchQuery) return runSearch({quiet});
|
|
2251
|
+
if (threadsOn) return loadThreads({fresh, quiet});
|
|
1588
2252
|
|
|
1589
|
-
const
|
|
2253
|
+
const seq = ++listSeq;
|
|
1590
2254
|
try {
|
|
1591
2255
|
const params = new URLSearchParams({
|
|
1592
2256
|
account: currentAccountId ?? '',
|
|
@@ -1595,7 +2259,8 @@ return {
|
|
|
1595
2259
|
limit: String(pageSize)
|
|
1596
2260
|
});
|
|
1597
2261
|
if (fresh) params.set('fresh', '1');
|
|
1598
|
-
const result = await api(`${BASE}/messages?${params}`);
|
|
2262
|
+
const result = await listLoad(() => api(`${BASE}/messages?${params}`), quiet);
|
|
2263
|
+
if (!listCurrent(seq)) return;
|
|
1599
2264
|
lastListSource = result.source ?? 'imap';
|
|
1600
2265
|
messages = result.messages;
|
|
1601
2266
|
total = result.total;
|
|
@@ -1603,9 +2268,7 @@ return {
|
|
|
1603
2268
|
renderList();
|
|
1604
2269
|
renderPager();
|
|
1605
2270
|
} catch (err) {
|
|
1606
|
-
|
|
1607
|
-
} finally {
|
|
1608
|
-
loader.destroy();
|
|
2271
|
+
listFailed(err, seq, quiet);
|
|
1609
2272
|
}
|
|
1610
2273
|
}
|
|
1611
2274
|
|
|
@@ -1620,8 +2283,8 @@ return {
|
|
|
1620
2283
|
*
|
|
1621
2284
|
* @returns {Promise<void>}
|
|
1622
2285
|
*/
|
|
1623
|
-
async function loadThreads({fresh = false} = {}) {
|
|
1624
|
-
const
|
|
2286
|
+
async function loadThreads({fresh = false, quiet = false} = {}) {
|
|
2287
|
+
const seq = ++listSeq;
|
|
1625
2288
|
try {
|
|
1626
2289
|
const params = new URLSearchParams({
|
|
1627
2290
|
account: currentAccountId ?? '',
|
|
@@ -1629,16 +2292,22 @@ return {
|
|
|
1629
2292
|
page: String(page),
|
|
1630
2293
|
limit: String(pageSize)
|
|
1631
2294
|
});
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2295
|
+
// Both requests inside the one load, so the skeleton covers
|
|
2296
|
+
// the fallback too.
|
|
2297
|
+
const {result, flat} = await listLoad(async () => {
|
|
2298
|
+
const grouped = await api(`${BASE}/threads?${params}`);
|
|
2299
|
+
if (grouped.mirrored) return {result: grouped, flat: null};
|
|
1636
2300
|
// The flat list, without turning the preference off: the
|
|
1637
2301
|
// next folder may well be mirrored, and silently
|
|
1638
2302
|
// forgetting the setting because one folder was not is
|
|
1639
2303
|
// worse than one line of explanation.
|
|
1640
2304
|
if (fresh) params.set('fresh', '1');
|
|
1641
|
-
|
|
2305
|
+
return {result: grouped, flat: await api(`${BASE}/messages?${params}`)};
|
|
2306
|
+
}, quiet);
|
|
2307
|
+
if (!listCurrent(seq)) return;
|
|
2308
|
+
|
|
2309
|
+
if (!result.mirrored) {
|
|
2310
|
+
note('This folder is not mirrored, so it cannot be grouped into conversations.');
|
|
1642
2311
|
lastListSource = flat.source ?? 'imap';
|
|
1643
2312
|
messages = flat.messages;
|
|
1644
2313
|
total = flat.total;
|
|
@@ -1655,9 +2324,7 @@ return {
|
|
|
1655
2324
|
renderList();
|
|
1656
2325
|
renderPager();
|
|
1657
2326
|
} catch (err) {
|
|
1658
|
-
|
|
1659
|
-
} finally {
|
|
1660
|
-
loader.destroy();
|
|
2327
|
+
listFailed(err, seq, quiet);
|
|
1661
2328
|
}
|
|
1662
2329
|
}
|
|
1663
2330
|
|
|
@@ -1746,8 +2413,8 @@ return {
|
|
|
1746
2413
|
*
|
|
1747
2414
|
* @returns {Promise<void>}
|
|
1748
2415
|
*/
|
|
1749
|
-
async function runSearch() {
|
|
1750
|
-
const
|
|
2416
|
+
async function runSearch({quiet = false} = {}) {
|
|
2417
|
+
const seq = ++listSeq;
|
|
1751
2418
|
try {
|
|
1752
2419
|
const params = new URLSearchParams({
|
|
1753
2420
|
account: currentAccountId ?? '',
|
|
@@ -1755,7 +2422,8 @@ return {
|
|
|
1755
2422
|
page: String(page),
|
|
1756
2423
|
limit: String(pageSize)
|
|
1757
2424
|
});
|
|
1758
|
-
const result = await api(`${BASE}/search?${params}`);
|
|
2425
|
+
const result = await listLoad(() => api(`${BASE}/search?${params}`), quiet);
|
|
2426
|
+
if (!listCurrent(seq)) return;
|
|
1759
2427
|
|
|
1760
2428
|
messages = result.messages;
|
|
1761
2429
|
total = result.total;
|
|
@@ -1777,9 +2445,7 @@ return {
|
|
|
1777
2445
|
note(`${result.total} result${result.total === 1 ? '' : 's'}. ${scope}`);
|
|
1778
2446
|
}
|
|
1779
2447
|
} catch (err) {
|
|
1780
|
-
|
|
1781
|
-
} finally {
|
|
1782
|
-
loader.destroy();
|
|
2448
|
+
listFailed(err, seq, quiet);
|
|
1783
2449
|
}
|
|
1784
2450
|
}
|
|
1785
2451
|
|
|
@@ -2244,7 +2910,7 @@ return {
|
|
|
2244
2910
|
? async () => {
|
|
2245
2911
|
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
2246
2912
|
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
2247
|
-
await loadMessages();
|
|
2913
|
+
await loadMessages({quiet: true});
|
|
2248
2914
|
}
|
|
2249
2915
|
: null
|
|
2250
2916
|
);
|
|
@@ -2257,7 +2923,8 @@ return {
|
|
|
2257
2923
|
*/
|
|
2258
2924
|
async function bulkDelete() {
|
|
2259
2925
|
const uids = [...selected];
|
|
2260
|
-
if (!await
|
|
2926
|
+
if (!await ask(`Delete ${uids.length} message${uids.length === 1 ? '' : 's'}?`,
|
|
2927
|
+
{confirmText: 'Delete', cancelText: 'Keep them'}) || !here()) return;
|
|
2261
2928
|
|
|
2262
2929
|
const source = currentFolder;
|
|
2263
2930
|
const params = new URLSearchParams({account: currentAccountId ?? '', folder: source});
|
|
@@ -2276,7 +2943,7 @@ return {
|
|
|
2276
2943
|
? async () => {
|
|
2277
2944
|
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
2278
2945
|
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
2279
|
-
await loadMessages();
|
|
2946
|
+
await loadMessages({quiet: true});
|
|
2280
2947
|
}
|
|
2281
2948
|
: null
|
|
2282
2949
|
);
|
|
@@ -2367,10 +3034,15 @@ return {
|
|
|
2367
3034
|
*/
|
|
2368
3035
|
function clearReader() {
|
|
2369
3036
|
cancelMarkRead();
|
|
3037
|
+
// A message still on its way must not land in the cleared pane.
|
|
3038
|
+
openSeq += 1;
|
|
2370
3039
|
readerHeadEl.textContent = '';
|
|
2371
3040
|
showEmpty(readerBodyEl, 'mail', 'Select a message to read it.');
|
|
2372
3041
|
}
|
|
2373
3042
|
|
|
3043
|
+
/** Bumped by every openMessage, so a slow answer cannot replace a newer one. */
|
|
3044
|
+
let openSeq = 0;
|
|
3045
|
+
|
|
2374
3046
|
/**
|
|
2375
3047
|
* Fetch and display one message.
|
|
2376
3048
|
*
|
|
@@ -2397,6 +3069,10 @@ return {
|
|
|
2397
3069
|
// resolve against the folder the message is actually in.
|
|
2398
3070
|
openFolder = folder ?? currentFolder;
|
|
2399
3071
|
|
|
3072
|
+
// Arrowing quickly through a list opens several in turn; only the
|
|
3073
|
+
// last one asked for may draw, and nothing draws once the screen
|
|
3074
|
+
// has gone.
|
|
3075
|
+
const seq = ++openSeq;
|
|
2400
3076
|
const loader = E.loader(readerBodyEl, {type: 'dots'});
|
|
2401
3077
|
try {
|
|
2402
3078
|
const params = new URLSearchParams({
|
|
@@ -2405,6 +3081,7 @@ return {
|
|
|
2405
3081
|
});
|
|
2406
3082
|
if (imagesAllowed.has(uid)) params.set('images', 'load');
|
|
2407
3083
|
const message = await api(`${BASE}/messages/${uid}?${params}`);
|
|
3084
|
+
if (!here() || seq !== openSeq) return;
|
|
2408
3085
|
renderMessage(message);
|
|
2409
3086
|
|
|
2410
3087
|
// The reading preference controls how long mail stays open
|
|
@@ -2426,7 +3103,7 @@ return {
|
|
|
2426
3103
|
}, readDelaySeconds(S.get(READ_DELAY_KEY)));
|
|
2427
3104
|
}
|
|
2428
3105
|
} catch (err) {
|
|
2429
|
-
handleError(err);
|
|
3106
|
+
if (here() && seq === openSeq) handleError(err);
|
|
2430
3107
|
} finally {
|
|
2431
3108
|
loader.destroy();
|
|
2432
3109
|
}
|
|
@@ -2807,7 +3484,9 @@ return {
|
|
|
2807
3484
|
* @returns {void}
|
|
2808
3485
|
*/
|
|
2809
3486
|
function showShortcuts() {
|
|
2810
|
-
|
|
3487
|
+
if (!shortcutsOnce.claim()) return;
|
|
3488
|
+
const modal = guardPanel(E.slideover({title: 'Keyboard shortcuts', size: 'sm', position: 'right'}),
|
|
3489
|
+
() => false, undefined, () => shortcutsOnce.release());
|
|
2811
3490
|
const list = document.createElement('div');
|
|
2812
3491
|
list.className = 'mail-kv';
|
|
2813
3492
|
for (const [keys, what] of SHORTCUTS) {
|
|
@@ -3290,7 +3969,7 @@ return {
|
|
|
3290
3969
|
mailboxAddress: profile?.mailboxAddress ?? '',
|
|
3291
3970
|
templates: profile?.templates ?? [],
|
|
3292
3971
|
undoSeconds: profile?.undoSendSeconds ?? 0,
|
|
3293
|
-
onSent: () => loadMessages(),
|
|
3972
|
+
onSent: () => loadMessages({quiet: true}),
|
|
3294
3973
|
onQueued: (result) => offerRecall(result),
|
|
3295
3974
|
...extra
|
|
3296
3975
|
};
|
|
@@ -3386,7 +4065,15 @@ return {
|
|
|
3386
4065
|
* @returns {Promise<void>}
|
|
3387
4066
|
*/
|
|
3388
4067
|
async function showOutbox() {
|
|
3389
|
-
|
|
4068
|
+
if (!outboxOnce.claim()) return;
|
|
4069
|
+
let slideover;
|
|
4070
|
+
try {
|
|
4071
|
+
slideover = guardPanel(E.slideover({title: 'Waiting', size: 'md', position: 'right'}),
|
|
4072
|
+
() => false, undefined, () => outboxOnce.release());
|
|
4073
|
+
} catch (err) {
|
|
4074
|
+
outboxOnce.release();
|
|
4075
|
+
throw err;
|
|
4076
|
+
}
|
|
3390
4077
|
const host = document.createElement('div');
|
|
3391
4078
|
host.className = 'mail-panel-stack';
|
|
3392
4079
|
slideover.element.appendChild(host);
|
|
@@ -3395,13 +4082,17 @@ return {
|
|
|
3395
4082
|
/**
|
|
3396
4083
|
* @returns {Promise<void>}
|
|
3397
4084
|
*/
|
|
3398
|
-
async function draw() {
|
|
3399
|
-
host.textContent = '';
|
|
4085
|
+
async function draw(first = false) {
|
|
3400
4086
|
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
3401
|
-
const
|
|
4087
|
+
const fetchBoth = () => Promise.all([
|
|
3402
4088
|
api(`${BASE}/outbox?${params}`).catch(() => ({queued: []})),
|
|
3403
4089
|
api(`${BASE}/snoozed?${params}`).catch(() => ({snoozed: []}))
|
|
3404
4090
|
]);
|
|
4091
|
+
// A skeleton the first time; a redraw keeps the rows on
|
|
4092
|
+
// screen until the new ones are in.
|
|
4093
|
+
const [outbox, snoozed] = first ? await withSkeleton(host, fetchBoth, 'rows') : await fetchBoth();
|
|
4094
|
+
if (!here() || !slideover.element?.isConnected) return;
|
|
4095
|
+
host.textContent = '';
|
|
3405
4096
|
|
|
3406
4097
|
host.appendChild(section('Waiting to send', (outbox.queued ?? []).map(row => ({
|
|
3407
4098
|
title: row.subject || '(no subject)',
|
|
@@ -3447,7 +4138,7 @@ return {
|
|
|
3447
4138
|
await api(`${BASE}/snoozed/${row.id}/wake`, 'POST');
|
|
3448
4139
|
await draw();
|
|
3449
4140
|
refreshOutbox();
|
|
3450
|
-
await loadMessages();
|
|
4141
|
+
await loadMessages({quiet: true});
|
|
3451
4142
|
}]
|
|
3452
4143
|
]
|
|
3453
4144
|
})), 'Nothing is snoozed.'));
|
|
@@ -3512,7 +4203,7 @@ return {
|
|
|
3512
4203
|
return wrap;
|
|
3513
4204
|
}
|
|
3514
4205
|
|
|
3515
|
-
await draw();
|
|
4206
|
+
await draw(true);
|
|
3516
4207
|
}
|
|
3517
4208
|
|
|
3518
4209
|
// ---------------------------------------------------------------
|
|
@@ -3530,7 +4221,7 @@ return {
|
|
|
3530
4221
|
*/
|
|
3531
4222
|
function askForTime(title) {
|
|
3532
4223
|
return new Promise((resolve) => {
|
|
3533
|
-
const modal = E.modal({title, size: 'sm'});
|
|
4224
|
+
const modal = trackModal(E.modal({title, size: 'sm'}));
|
|
3534
4225
|
let answered = false;
|
|
3535
4226
|
|
|
3536
4227
|
/**
|
|
@@ -3596,8 +4287,14 @@ return {
|
|
|
3596
4287
|
* @returns {Promise<void>}
|
|
3597
4288
|
*/
|
|
3598
4289
|
async function snooze(message) {
|
|
3599
|
-
|
|
3600
|
-
|
|
4290
|
+
if (!dialogOnce.claim()) return;
|
|
4291
|
+
let at;
|
|
4292
|
+
try {
|
|
4293
|
+
at = await askForTime('Snooze until');
|
|
4294
|
+
} finally {
|
|
4295
|
+
dialogOnce.release();
|
|
4296
|
+
}
|
|
4297
|
+
if (!at || !here()) return;
|
|
3601
4298
|
|
|
3602
4299
|
try {
|
|
3603
4300
|
const params = new URLSearchParams({
|
|
@@ -3617,7 +4314,7 @@ return {
|
|
|
3617
4314
|
// already moved, so there is no "leave it where it was".
|
|
3618
4315
|
async () => {
|
|
3619
4316
|
await api(`${BASE}/snoozed/${result.id}/wake`, 'POST');
|
|
3620
|
-
await loadMessages();
|
|
4317
|
+
await loadMessages({quiet: true});
|
|
3621
4318
|
}
|
|
3622
4319
|
);
|
|
3623
4320
|
refreshOutbox();
|
|
@@ -3638,7 +4335,9 @@ return {
|
|
|
3638
4335
|
* @returns {Promise<void>}
|
|
3639
4336
|
*/
|
|
3640
4337
|
async function unsubscribe(message) {
|
|
3641
|
-
|
|
4338
|
+
// The subject is a stranger's text, and E.confirm renders HTML.
|
|
4339
|
+
if (!await ask(esc(`Unsubscribe from the list "${message.subject || 'this message'}" came from?`),
|
|
4340
|
+
{confirmText: 'Unsubscribe', cancelText: 'Stay subscribed'}) || !here()) return;
|
|
3642
4341
|
|
|
3643
4342
|
try {
|
|
3644
4343
|
const params = new URLSearchParams({
|
|
@@ -3720,7 +4419,8 @@ return {
|
|
|
3720
4419
|
E.toast(`Created the draft page ${result.urlPath}.`, {type: 'success'});
|
|
3721
4420
|
// Offered rather than taken: leaving the mail view loses
|
|
3722
4421
|
// whatever was half-read in it.
|
|
3723
|
-
if (await
|
|
4422
|
+
if (await ask(esc(`Open "${result.title}" in the page editor now?`),
|
|
4423
|
+
{confirmText: 'Open it', cancelText: 'Not now'}) && here()) {
|
|
3724
4424
|
location.hash = `#/pages/edit${result.urlPath}`;
|
|
3725
4425
|
}
|
|
3726
4426
|
} catch (err) {
|
|
@@ -3739,13 +4439,30 @@ return {
|
|
|
3739
4439
|
* @returns {Promise<void>}
|
|
3740
4440
|
*/
|
|
3741
4441
|
async function messageToEntry(message) {
|
|
4442
|
+
// One at a time; released when the dialog closes, or below if
|
|
4443
|
+
// it never opens.
|
|
4444
|
+
if (!dialogOnce.claim()) return;
|
|
4445
|
+
let shown = false;
|
|
4446
|
+
try {
|
|
4447
|
+
shown = await fileAsEntry(message);
|
|
4448
|
+
} finally {
|
|
4449
|
+
if (!shown) dialogOnce.release();
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
/**
|
|
4454
|
+
* @param {object} message
|
|
4455
|
+
* @returns {Promise<boolean>} whether the dialog opened
|
|
4456
|
+
*/
|
|
4457
|
+
async function fileAsEntry(message) {
|
|
3742
4458
|
let available;
|
|
3743
4459
|
try {
|
|
3744
4460
|
available = await api(`${BASE}/collections`);
|
|
3745
4461
|
} catch (err) {
|
|
3746
|
-
E.toast(err.message || 'The collections could not be listed.', {type: 'error'});
|
|
3747
|
-
return;
|
|
4462
|
+
if (here()) E.toast(err.message || 'The collections could not be listed.', {type: 'error'});
|
|
4463
|
+
return false;
|
|
3748
4464
|
}
|
|
4465
|
+
if (!here()) return false;
|
|
3749
4466
|
|
|
3750
4467
|
// A collection whose required fields a message cannot fill is not
|
|
3751
4468
|
// usable, however good the rest of the mapping looks - choosing
|
|
@@ -3762,10 +4479,10 @@ return {
|
|
|
3762
4479
|
: 'No collection has fields a message could be filed into.',
|
|
3763
4480
|
{type: 'warning'}
|
|
3764
4481
|
);
|
|
3765
|
-
return;
|
|
4482
|
+
return false;
|
|
3766
4483
|
}
|
|
3767
4484
|
|
|
3768
|
-
const modal = E.modal({title: 'File as a collection entry', size: 'sm'});
|
|
4485
|
+
const modal = trackModal(E.modal({title: 'File as a collection entry', size: 'sm'}), () => dialogOnce.release());
|
|
3769
4486
|
const panel = document.createElement('div');
|
|
3770
4487
|
panel.className = 'mail-dialog-stack';
|
|
3771
4488
|
|
|
@@ -3835,6 +4552,7 @@ return {
|
|
|
3835
4552
|
|
|
3836
4553
|
modal.element.appendChild(panel);
|
|
3837
4554
|
modal.open();
|
|
4555
|
+
return true;
|
|
3838
4556
|
}
|
|
3839
4557
|
|
|
3840
4558
|
/**
|
|
@@ -4068,7 +4786,7 @@ return {
|
|
|
4068
4786
|
});
|
|
4069
4787
|
await api(`${BASE}/messages/move?${back}`, 'POST',
|
|
4070
4788
|
{uids: mapped, destination: from});
|
|
4071
|
-
await loadMessages();
|
|
4789
|
+
await loadMessages({quiet: true});
|
|
4072
4790
|
}
|
|
4073
4791
|
: null
|
|
4074
4792
|
);
|
|
@@ -4093,12 +4811,21 @@ return {
|
|
|
4093
4811
|
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
4094
4812
|
if (typeof elements?.contextMenu !== 'function' || !foldersEl) return;
|
|
4095
4813
|
|
|
4814
|
+
// One registration for both kinds of row - the mailbox rows and
|
|
4815
|
+
// the folders - since they share the pane.
|
|
4096
4816
|
folderMenu = elements.contextMenu(foldersEl, {
|
|
4097
4817
|
cursor: 'glyph',
|
|
4098
|
-
match: '.mail-folder',
|
|
4818
|
+
match: '.mail-folder, .mail-account',
|
|
4099
4819
|
inherit: false,
|
|
4100
4820
|
items: [],
|
|
4101
4821
|
onBeforeOpen: (ctx) => {
|
|
4822
|
+
const account = ctx?.target?.closest?.('.mail-account');
|
|
4823
|
+
if (account) {
|
|
4824
|
+
const box = mailboxes.find(b => b.id === account.dataset.account);
|
|
4825
|
+
if (!box) return false;
|
|
4826
|
+
folderMenu.setItems(accountMenuItemsFor(box));
|
|
4827
|
+
return true;
|
|
4828
|
+
}
|
|
4102
4829
|
const row = ctx?.target?.closest?.('.mail-folder');
|
|
4103
4830
|
if (!row) return false;
|
|
4104
4831
|
const path = row.dataset.path;
|
|
@@ -4110,6 +4837,47 @@ return {
|
|
|
4110
4837
|
});
|
|
4111
4838
|
}
|
|
4112
4839
|
|
|
4840
|
+
/**
|
|
4841
|
+
* A mailbox row's menu, in the folder pane.
|
|
4842
|
+
*
|
|
4843
|
+
* @param {object} box
|
|
4844
|
+
* @returns {object[]}
|
|
4845
|
+
*/
|
|
4846
|
+
function accountMenuItemsFor(box) {
|
|
4847
|
+
return menuItems(accountMenuModel({
|
|
4848
|
+
isCurrent: box.id === currentAccountId,
|
|
4849
|
+
isDefault: box.isDefault === true,
|
|
4850
|
+
count: mailboxes.length
|
|
4851
|
+
}), {
|
|
4852
|
+
open: () => selectAccount(box.id),
|
|
4853
|
+
refresh: () => refreshMailbox(),
|
|
4854
|
+
edit: () => openMailboxForm(box),
|
|
4855
|
+
default: () => toggleDefaultMailbox(box),
|
|
4856
|
+
remove: () => removeMailbox(box)
|
|
4857
|
+
});
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
/**
|
|
4861
|
+
* What every edition offers on a folder - open it, fold it, copy
|
|
4862
|
+
* its path. The free reader's whole menu; Pro adds management below.
|
|
4863
|
+
*
|
|
4864
|
+
* @param {object} folder - a node from the folder tree
|
|
4865
|
+
* @returns {object[]}
|
|
4866
|
+
*/
|
|
4867
|
+
function folderReadItems(folder) {
|
|
4868
|
+
const node = flattenFolderTree(buildFolderTree(folders), new Set()).find(n => n.path === folder.path) ?? folder;
|
|
4869
|
+
return menuItems(folderReadMenuModel({
|
|
4870
|
+
selectable: node.selectable !== false,
|
|
4871
|
+
hasChildren: (node.children ?? []).length > 0,
|
|
4872
|
+
collapsed: collapsedSet().has(folder.path),
|
|
4873
|
+
isCurrent: folder.path === currentFolder && !searchQuery
|
|
4874
|
+
}), {
|
|
4875
|
+
open: () => selectFolder(node),
|
|
4876
|
+
fold: () => toggleFolder(folder.path),
|
|
4877
|
+
copy: () => copyText(folder.path, 'Folder path copied.')
|
|
4878
|
+
});
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4113
4881
|
/**
|
|
4114
4882
|
* The items for one folder.
|
|
4115
4883
|
*
|
|
@@ -4126,7 +4894,11 @@ return {
|
|
|
4126
4894
|
const isInbox = String(folder.path).toUpperCase() === 'INBOX';
|
|
4127
4895
|
const locked = protectedUse || isInbox;
|
|
4128
4896
|
|
|
4897
|
+
const read = folderReadItems(folder);
|
|
4898
|
+
if (!features.folders) return read;
|
|
4129
4899
|
return [
|
|
4900
|
+
...read,
|
|
4901
|
+
{type: 'divider'},
|
|
4130
4902
|
{
|
|
4131
4903
|
label: 'New folder',
|
|
4132
4904
|
icon: 'folder-plus',
|
|
@@ -4176,15 +4948,24 @@ return {
|
|
|
4176
4948
|
// infer a parent from, so the parent has to be part of the ask -
|
|
4177
4949
|
// otherwise every folder made that way lands at the top level and
|
|
4178
4950
|
// has to be moved afterwards.
|
|
4179
|
-
|
|
4180
|
-
if (!
|
|
4951
|
+
// One question at a time: a double click must not stack two.
|
|
4952
|
+
if (!dialogOnce.claim()) return;
|
|
4953
|
+
let chosen;
|
|
4954
|
+
try {
|
|
4955
|
+
chosen = pickParent ? await askForFolder(parent) : {parent, name: null};
|
|
4956
|
+
} finally {
|
|
4957
|
+
dialogOnce.release();
|
|
4958
|
+
}
|
|
4959
|
+
if (!chosen || !here()) return;
|
|
4181
4960
|
|
|
4182
|
-
const name = chosen.name ?? await
|
|
4183
|
-
chosen.parent
|
|
4961
|
+
const name = chosen.name ?? await promptWith(
|
|
4962
|
+
esc(chosen.parent
|
|
4184
4963
|
? `Name for the new folder inside ${chosen.parent}`
|
|
4185
|
-
: 'Name for the new folder'
|
|
4964
|
+
: 'Name for the new folder'),
|
|
4965
|
+
{title: 'New folder', confirmText: 'Create'},
|
|
4966
|
+
''
|
|
4186
4967
|
);
|
|
4187
|
-
if (!name || !String(name).trim()) return;
|
|
4968
|
+
if (!here() || !name || !String(name).trim()) return;
|
|
4188
4969
|
parent = chosen.parent;
|
|
4189
4970
|
|
|
4190
4971
|
try {
|
|
@@ -4192,7 +4973,7 @@ return {
|
|
|
4192
4973
|
const result = await api(`${BASE}/folders?${params}`, 'POST',
|
|
4193
4974
|
{name: String(name).trim(), parent: parent ?? ''});
|
|
4194
4975
|
E.toast(`Created ${result.path}.`, {type: 'success'});
|
|
4195
|
-
await loadFolders();
|
|
4976
|
+
await loadFolders({quiet: true});
|
|
4196
4977
|
} catch (err) {
|
|
4197
4978
|
E.toast(err.message || 'That folder could not be created.', {type: 'error'});
|
|
4198
4979
|
}
|
|
@@ -4215,7 +4996,7 @@ return {
|
|
|
4215
4996
|
*/
|
|
4216
4997
|
function askForFolder(preselect = null) {
|
|
4217
4998
|
return new Promise(resolve => {
|
|
4218
|
-
const modal = E.modal({title: 'New folder', size: 'sm'});
|
|
4999
|
+
const modal = trackModal(E.modal({title: 'New folder', size: 'sm'}));
|
|
4219
5000
|
|
|
4220
5001
|
const wrap = document.createElement('div');
|
|
4221
5002
|
wrap.className = 'mail-form-stack';
|
|
@@ -4321,7 +5102,7 @@ return {
|
|
|
4321
5102
|
visible ? `${folder.name} will show in the list.` : `${folder.name} is hidden.`,
|
|
4322
5103
|
{type: 'success'}
|
|
4323
5104
|
);
|
|
4324
|
-
await loadFolders();
|
|
5105
|
+
await loadFolders({quiet: true});
|
|
4325
5106
|
} catch (err) {
|
|
4326
5107
|
E.toast(err.message || 'That could not be changed.', {type: 'error'});
|
|
4327
5108
|
}
|
|
@@ -4334,8 +5115,11 @@ return {
|
|
|
4334
5115
|
* @returns {Promise<void>}
|
|
4335
5116
|
*/
|
|
4336
5117
|
async function renameFolder(folder) {
|
|
4337
|
-
|
|
4338
|
-
|
|
5118
|
+
// The current name goes in as the input's value, after the
|
|
5119
|
+
// dialog is built - `inputValue` would cut it at a quote.
|
|
5120
|
+
const name = await promptWith(esc(`Rename "${folder.name}" to`),
|
|
5121
|
+
{title: 'Rename folder', confirmText: 'Rename'}, folder.name);
|
|
5122
|
+
if (!here() || !name || String(name).trim() === folder.name) return;
|
|
4339
5123
|
|
|
4340
5124
|
try {
|
|
4341
5125
|
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
@@ -4346,7 +5130,7 @@ return {
|
|
|
4346
5130
|
// exists under that name.
|
|
4347
5131
|
if (currentFolder === folder.path) currentFolder = result.path;
|
|
4348
5132
|
E.toast(`Renamed to ${result.path}.`, {type: 'success'});
|
|
4349
|
-
await loadFolders();
|
|
5133
|
+
await loadFolders({quiet: true});
|
|
4350
5134
|
} catch (err) {
|
|
4351
5135
|
E.toast(err.message || 'That folder could not be renamed.', {type: 'error'});
|
|
4352
5136
|
}
|
|
@@ -4369,7 +5153,8 @@ return {
|
|
|
4369
5153
|
const question = children.length
|
|
4370
5154
|
? `Delete "${folder.path}" and the ${children.length} folder${children.length === 1 ? '' : 's'} inside it (${children.join(', ')})? Every message in them is deleted too.`
|
|
4371
5155
|
: `Delete "${folder.path}"? Every message in it is deleted too.`;
|
|
4372
|
-
|
|
5156
|
+
// Folder names are the server's text, and E.confirm renders HTML.
|
|
5157
|
+
if (!await ask(esc(question), {title: 'Delete folder', confirmText: 'Delete', cancelText: 'Keep it'}) || !here()) return;
|
|
4373
5158
|
|
|
4374
5159
|
try {
|
|
4375
5160
|
const params = new URLSearchParams({
|
|
@@ -4383,7 +5168,7 @@ return {
|
|
|
4383
5168
|
}
|
|
4384
5169
|
const extra = result.alsoDeleted?.length ? ` and ${result.alsoDeleted.length} inside it` : '';
|
|
4385
5170
|
E.toast(`Deleted ${result.path}${extra}.`, {type: 'success'});
|
|
4386
|
-
await loadFolders();
|
|
5171
|
+
await loadFolders({quiet: true});
|
|
4387
5172
|
} catch (err) {
|
|
4388
5173
|
E.toast(err.message || 'That folder could not be deleted.', {type: 'error'});
|
|
4389
5174
|
}
|
|
@@ -4444,7 +5229,7 @@ return {
|
|
|
4444
5229
|
// Saving or sending changes which UIDs are in Drafts, so
|
|
4445
5230
|
// the list behind the window has to be refetched either
|
|
4446
5231
|
// way - not only on send.
|
|
4447
|
-
onSaved: () => loadMessages()
|
|
5232
|
+
onSaved: () => loadMessages({quiet: true})
|
|
4448
5233
|
}));
|
|
4449
5234
|
} catch (err) {
|
|
4450
5235
|
E.toast(err.message || 'That draft could not be opened.', {type: 'error'});
|
|
@@ -4627,7 +5412,7 @@ return {
|
|
|
4627
5412
|
async function undoMove({uid, from, to}) {
|
|
4628
5413
|
const params = new URLSearchParams({account: currentAccountId ?? '', folder: from});
|
|
4629
5414
|
await api(`${BASE}/messages/${uid}/move?${params}`, 'POST', {destination: to});
|
|
4630
|
-
await loadMessages();
|
|
5415
|
+
await loadMessages({quiet: true});
|
|
4631
5416
|
}
|
|
4632
5417
|
|
|
4633
5418
|
/**
|
|
@@ -4737,11 +5522,28 @@ return {
|
|
|
4737
5522
|
if (!folder || !Number.isFinite(folder.unseen)) return;
|
|
4738
5523
|
folder.unseen = Math.max(0, folder.unseen + delta);
|
|
4739
5524
|
renderFolders();
|
|
4740
|
-
// The sidebar's unread badge counts the mirror; ask it to look
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
5525
|
+
// The sidebar's unread badge counts the mirror; ask it to look again.
|
|
5526
|
+
refreshBadge();
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
/**
|
|
5530
|
+
* Ask the sidebar to re-read this Tool's badge. Named by the plugin
|
|
5531
|
+
* this view was built for (#/plugins/<slug>) - the reader is shared by
|
|
5532
|
+
* more than one mail plugin, and the address may carry more after it.
|
|
5533
|
+
*
|
|
5534
|
+
* @returns {void}
|
|
5535
|
+
*/
|
|
5536
|
+
function refreshBadge() {
|
|
5537
|
+
sidebarCount(sidebarUrl(pluginName));
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
/**
|
|
5541
|
+
* What the list shows, enough to tell whether a reload brought mail.
|
|
5542
|
+
*
|
|
5543
|
+
* @returns {{total: number, uids: number[]}}
|
|
5544
|
+
*/
|
|
5545
|
+
function listSnapshot() {
|
|
5546
|
+
return {total, uids: messages.map(m => m.uid)};
|
|
4745
5547
|
}
|
|
4746
5548
|
|
|
4747
5549
|
/**
|
|
@@ -4830,15 +5632,38 @@ return {
|
|
|
4830
5632
|
// Boot
|
|
4831
5633
|
// ---------------------------------------------------------------
|
|
4832
5634
|
|
|
5635
|
+
/** Bumped by every folder load, so a mailbox switched away from cannot draw. */
|
|
5636
|
+
let foldersSeq = 0;
|
|
5637
|
+
|
|
4833
5638
|
/**
|
|
4834
5639
|
* Load the selected mailbox's folders and open its inbox.
|
|
4835
5640
|
*
|
|
4836
5641
|
* @returns {Promise<void>}
|
|
4837
5642
|
*/
|
|
4838
|
-
async function loadFolders() {
|
|
5643
|
+
async function loadFolders({quiet = false} = {}) {
|
|
5644
|
+
const seq = ++foldersSeq;
|
|
4839
5645
|
try {
|
|
4840
5646
|
const params = new URLSearchParams({account: currentAccountId ?? '', counts: '1'});
|
|
4841
|
-
|
|
5647
|
+
let result;
|
|
5648
|
+
if (quiet) {
|
|
5649
|
+
// A reload after a folder was made, renamed, hidden or
|
|
5650
|
+
// deleted: the pane stays as it is until the answer.
|
|
5651
|
+
result = await api(`${BASE}/folders?${params}`);
|
|
5652
|
+
} else {
|
|
5653
|
+
// Another mailbox: nothing to show until this answers -
|
|
5654
|
+
// the mailbox rows stay, the old mailbox's folders go, and
|
|
5655
|
+
// a skeleton stands where the new ones will be. The
|
|
5656
|
+
// message list waits with it.
|
|
5657
|
+
folders = [];
|
|
5658
|
+
renderFolders();
|
|
5659
|
+
if (!searchQuery) {
|
|
5660
|
+
disposeRowEffects();
|
|
5661
|
+
listEl.textContent = '';
|
|
5662
|
+
pagerEl.textContent = '';
|
|
5663
|
+
}
|
|
5664
|
+
result = await withSkeleton(foldersEl, () => api(`${BASE}/folders?${params}`), 'rows');
|
|
5665
|
+
}
|
|
5666
|
+
if (!here() || seq !== foldersSeq) return;
|
|
4842
5667
|
folders = result.folders;
|
|
4843
5668
|
currentAccountId = result.accountId;
|
|
4844
5669
|
rememberAccount(currentAccountId);
|
|
@@ -4861,7 +5686,15 @@ return {
|
|
|
4861
5686
|
: 'Inbox';
|
|
4862
5687
|
renderFolders();
|
|
4863
5688
|
} catch (err) {
|
|
5689
|
+
if (!here() || seq !== foldersSeq) return;
|
|
5690
|
+
renderFolders();
|
|
4864
5691
|
handleError(err);
|
|
5692
|
+
if (!err.needsSetup && !foldersEl.querySelector('.mail-folder')) {
|
|
5693
|
+
const note = document.createElement('p');
|
|
5694
|
+
note.className = 'text-sm text-muted mail-none';
|
|
5695
|
+
note.textContent = 'The folders could not be loaded. Refresh to try again.';
|
|
5696
|
+
foldersEl.appendChild(note);
|
|
5697
|
+
}
|
|
4865
5698
|
return;
|
|
4866
5699
|
}
|
|
4867
5700
|
|
|
@@ -4894,8 +5727,17 @@ return {
|
|
|
4894
5727
|
refreshContacts();
|
|
4895
5728
|
loadMessageActions(api).then(actions => { messageActions = actions; });
|
|
4896
5729
|
|
|
4897
|
-
|
|
5730
|
+
// The first visit has nothing in the folder pane yet: a
|
|
5731
|
+
// skeleton while the mailboxes are asked for. A reload (after
|
|
5732
|
+
// a save) keeps what is there.
|
|
5733
|
+
const answer = mailboxes.length
|
|
5734
|
+
? await api(`${BASE}/accounts`)
|
|
5735
|
+
: await withSkeleton(foldersEl, () => api(`${BASE}/accounts`), 'rows');
|
|
5736
|
+
if (!here()) return;
|
|
5737
|
+
mailboxes = answer.accounts;
|
|
5738
|
+
pruneMailboxDrafts();
|
|
4898
5739
|
} catch (err) {
|
|
5740
|
+
if (!here()) return;
|
|
4899
5741
|
E.toast(err.message, {type: 'error'});
|
|
4900
5742
|
return;
|
|
4901
5743
|
}
|
|
@@ -4968,16 +5810,18 @@ return {
|
|
|
4968
5810
|
// this one can do (readOnlyMenuItems for the free reader).
|
|
4969
5811
|
attachMessageMenu();
|
|
4970
5812
|
attachBodyMenu();
|
|
4971
|
-
|
|
5813
|
+
// Every edition: the free reader's folders and mailbox rows have a
|
|
5814
|
+
// menu too (open, fold, copy; edit or remove a mailbox).
|
|
5815
|
+
attachFolderMenu();
|
|
4972
5816
|
|
|
4973
5817
|
// Shift+F10 opens the menu of the focused row, as the keyboard's
|
|
4974
5818
|
// right-click - the rows are buttons, so Enter already opens them.
|
|
4975
5819
|
const onMenuKey = (event) => {
|
|
4976
5820
|
if (!(event.key === 'F10' && event.shiftKey)) return;
|
|
4977
|
-
const row = event.target.closest?.('.mail-item, .mail-folder');
|
|
5821
|
+
const row = event.target.closest?.('.mail-item, .mail-folder, .mail-account');
|
|
4978
5822
|
if (!row) return;
|
|
4979
5823
|
event.preventDefault();
|
|
4980
|
-
openMenuAt(row);
|
|
5824
|
+
openMenuAt(row.querySelector('.mail-item-more, .mail-row-more') ?? row);
|
|
4981
5825
|
};
|
|
4982
5826
|
listEl?.addEventListener('keydown', onMenuKey);
|
|
4983
5827
|
foldersEl?.addEventListener('keydown', onMenuKey);
|
|
@@ -5131,7 +5975,11 @@ return {
|
|
|
5131
5975
|
: result.total !== total || signature(result.messages) !== signature(messages);
|
|
5132
5976
|
observedThreadPage = snapshot;
|
|
5133
5977
|
if (changed) {
|
|
5134
|
-
|
|
5978
|
+
const before = listSnapshot();
|
|
5979
|
+
await loadMessages({quiet: true});
|
|
5980
|
+
// New mail arrived while the screen sat open: the
|
|
5981
|
+
// sidebar's unread badge is the other place it shows.
|
|
5982
|
+
if (here() && hasNewMail(before, listSnapshot())) refreshBadge();
|
|
5135
5983
|
}
|
|
5136
5984
|
} catch { /* A temporary failure must not replace the open reader. */ }
|
|
5137
5985
|
finally { refreshing = false; }
|