domma-cms 1.6.56 → 1.6.58
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 +453 -0
- package/plugins/_lib/admin/mail/reader-view.js +1207 -165
- 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, extensionMenuItems, extensionPaneActions, mergeExtensionItems
|
|
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.
|
|
@@ -159,7 +183,18 @@ function formatSize(bytes) {
|
|
|
159
183
|
/**
|
|
160
184
|
* Build the reader view for one plugin.
|
|
161
185
|
*
|
|
162
|
-
* @param {{pluginName: string, assetVersion?: string, settingsSections?: object[]
|
|
186
|
+
* @param {{pluginName: string, assetVersion?: string, settingsSections?: object[],
|
|
187
|
+
* features?: object, extensions?: object}} options
|
|
188
|
+
* `extensions` lets an edition add actions to the reader itself, each a
|
|
189
|
+
* function given the reader's `view` handle (see `extensionView()`):
|
|
190
|
+
* - `paneActions(view)` -> `[{id, label, icon, onClick, haspopup?}]`, buttons
|
|
191
|
+
* in the Folders pane head beside New folder;
|
|
192
|
+
* - `folderItems(folder, view)` -> Domma menu items, added to a folder's
|
|
193
|
+
* menu (right-click, ⋮, Shift+F10);
|
|
194
|
+
* - `mailboxItems(account, view)` -> the same, on a mailbox row's menu.
|
|
195
|
+
* Optional, checked and feature-detected: an edition that passes it to an
|
|
196
|
+
* older reader just does not get the entries. Item actions run on the next
|
|
197
|
+
* task, so one picked with Enter cannot hand its own Enter to a dialog.
|
|
163
198
|
* `settingsSections` lets an edition contribute settings without forking the
|
|
164
199
|
* view: each is `{id, label, render(container, context)}`, and the shell
|
|
165
200
|
* handles the tabs, the lazy drawing and a section that throws.
|
|
@@ -169,7 +204,8 @@ function formatSize(bytes) {
|
|
|
169
204
|
* module is imported by a fixed path and carries no stamp of its own.
|
|
170
205
|
* @returns {object} a Domma view
|
|
171
206
|
*/
|
|
172
|
-
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {}
|
|
207
|
+
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
|
|
208
|
+
extensions = {}}) {
|
|
173
209
|
const BASE = `/api/plugins/${pluginName}`;
|
|
174
210
|
|
|
175
211
|
// Deliberately NOT keyed by plugin. The two editions never run together - the
|
|
@@ -516,44 +552,150 @@ return {
|
|
|
516
552
|
* removing it.
|
|
517
553
|
*/
|
|
518
554
|
const openPanels = new Set();
|
|
555
|
+
/** Panels that open once at a time, each released when it closes. */
|
|
556
|
+
const settingsOnce = onceOnly();
|
|
557
|
+
const mailboxFormOnce = onceOnly();
|
|
558
|
+
const outboxOnce = onceOnly();
|
|
559
|
+
const shortcutsOnce = onceOnly();
|
|
560
|
+
const dialogOnce = onceOnly();
|
|
519
561
|
|
|
520
562
|
/**
|
|
521
563
|
* Guard a slideover: asks once before a close would throw away typed
|
|
522
564
|
* changes (Cancel, the X, Escape, the backdrop all call close()), and
|
|
523
565
|
* goes straight through after that - a later close() from cleanup must
|
|
524
|
-
* not ask again.
|
|
566
|
+
* not ask again. A forced close (the screen being left) never asks:
|
|
567
|
+
* whatever is worth keeping is already in a draft.
|
|
568
|
+
*
|
|
569
|
+
* Cancel, Escape and the backdrop all answer "keep editing" - E.confirm
|
|
570
|
+
* cannot tell them apart - so OK is the one that discards.
|
|
525
571
|
*
|
|
526
572
|
* @param {object} slideover
|
|
527
573
|
* @param {Function} [dirty] - () => boolean
|
|
528
|
-
* @param {string}
|
|
574
|
+
* @param {string|Function} [question] - plain text (escaped here), or () => text
|
|
575
|
+
* @param {Function} [onClosed]
|
|
576
|
+
* @param {{onDiscard?: Function, confirmText?: string, cancelText?: string}} [options]
|
|
577
|
+
* `onDiscard` runs once the person has agreed to lose the changes
|
|
529
578
|
* @returns {object} the slideover
|
|
530
579
|
*/
|
|
531
|
-
function guardPanel(slideover, dirty = () => false, question = 'Discard the changes you have made?',
|
|
580
|
+
function guardPanel(slideover, dirty = () => false, question = 'Discard the changes you have made?',
|
|
581
|
+
onClosed = () => {}, {onDiscard = () => {}, confirmText = 'Discard', cancelText = 'Keep editing'} = {}) {
|
|
532
582
|
const rawClose = slideover.close.bind(slideover);
|
|
533
583
|
let closed = false;
|
|
534
584
|
let asking = false;
|
|
585
|
+
// Escape belongs to the top panel: the ones already open under
|
|
586
|
+
// this one stop answering it until this one has gone. Taken when
|
|
587
|
+
// it OPENS, not here - a panel whose building throws before
|
|
588
|
+
// open() must not leave the ones below deaf to Escape.
|
|
589
|
+
let releaseBelow = () => {};
|
|
590
|
+
const rawOpen = slideover.open.bind(slideover);
|
|
591
|
+
slideover.open = (...args) => {
|
|
592
|
+
if (!closed && !openPanels.has(slideover)) {
|
|
593
|
+
releaseBelow = holdEscapeBelow();
|
|
594
|
+
openPanels.add(slideover);
|
|
595
|
+
}
|
|
596
|
+
return rawOpen(...args);
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* Give up on a panel that failed before it opened: nothing held,
|
|
600
|
+
* nothing tracked, nothing left in the page.
|
|
601
|
+
*/
|
|
602
|
+
slideover.__mailAbandon = () => {
|
|
603
|
+
closed = true;
|
|
604
|
+
openPanels.delete(slideover);
|
|
605
|
+
releaseBelow();
|
|
606
|
+
try { onClosed(); } catch { /* it never opened */ }
|
|
607
|
+
try { slideover.destroy?.(); } catch { /* gone */ }
|
|
608
|
+
try { slideover.element?.remove(); } catch { /* gone */ }
|
|
609
|
+
};
|
|
535
610
|
slideover.close = async (force) => {
|
|
536
611
|
if (!closed && force !== true && dirty()) {
|
|
537
612
|
if (asking) return undefined;
|
|
538
613
|
asking = true;
|
|
539
|
-
|
|
540
|
-
|
|
614
|
+
let ok = false;
|
|
615
|
+
try {
|
|
616
|
+
const text = typeof question === 'function' ? question() : question;
|
|
617
|
+
ok = await ask(esc(text), {confirmText, cancelText});
|
|
618
|
+
} finally {
|
|
619
|
+
asking = false;
|
|
620
|
+
}
|
|
541
621
|
if (!ok) return undefined;
|
|
622
|
+
try { onDiscard(); } catch { /* the close still goes ahead */ }
|
|
542
623
|
}
|
|
543
624
|
if (closed) return undefined;
|
|
544
625
|
closed = true;
|
|
545
626
|
return rawClose();
|
|
546
627
|
};
|
|
547
|
-
openPanels.add(slideover);
|
|
548
628
|
whenClosed(slideover, () => {
|
|
549
629
|
closed = true;
|
|
550
630
|
openPanels.delete(slideover);
|
|
551
|
-
|
|
631
|
+
// After the key press that closed it has finished travelling -
|
|
632
|
+
// the panel below may still be about to hear the same Escape.
|
|
633
|
+
setTimeout(releaseBelow, 0);
|
|
634
|
+
try { onClosed(); } catch { /* the panel is going regardless */ }
|
|
552
635
|
setTimeout(() => { try { slideover.element?.remove(); } catch { /* gone */ } }, 400);
|
|
553
636
|
});
|
|
554
637
|
return slideover;
|
|
555
638
|
}
|
|
556
639
|
|
|
640
|
+
/**
|
|
641
|
+
* A Domma modal this view opened: closed when the screen goes, and
|
|
642
|
+
* taken out of the page once closed (a closed `<domma-modal>` stays
|
|
643
|
+
* in the document otherwise, one per use).
|
|
644
|
+
*
|
|
645
|
+
* @param {object} modal - from E.modal
|
|
646
|
+
* @param {Function} [onClosed]
|
|
647
|
+
* @returns {object} the modal
|
|
648
|
+
*/
|
|
649
|
+
function trackModal(modal, onClosed = () => {}) {
|
|
650
|
+
const entry = {close: () => { try { modal.close(); } catch { /* gone */ } }};
|
|
651
|
+
const releaseBelow = holdEscapeBelow();
|
|
652
|
+
openPanels.add(entry);
|
|
653
|
+
whenClosed(modal, () => {
|
|
654
|
+
openPanels.delete(entry);
|
|
655
|
+
setTimeout(releaseBelow, 0);
|
|
656
|
+
try { onClosed(); } catch { /* going regardless */ }
|
|
657
|
+
setTimeout(() => { try { modal.element?.remove(); } catch { /* gone */ } }, 400);
|
|
658
|
+
});
|
|
659
|
+
return modal;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* E.confirm, answered "Cancel" for you if the screen is left while it
|
|
664
|
+
* is open - so it does not linger over the next screen, and a caller
|
|
665
|
+
* checking here() afterwards does nothing. Ask so that Cancel is never
|
|
666
|
+
* the destructive answer: Escape and the backdrop answer the same.
|
|
667
|
+
* The text is the caller's to escape (see esc()).
|
|
668
|
+
*
|
|
669
|
+
* @param {string} html
|
|
670
|
+
* @param {object} [options]
|
|
671
|
+
* @returns {Promise<boolean>}
|
|
672
|
+
*/
|
|
673
|
+
async function ask(html, options = {}) {
|
|
674
|
+
// Escape answers the question; it must not close the panel too.
|
|
675
|
+
const releaseBelow = holdEscapeBelow();
|
|
676
|
+
const before = new Set(document.querySelectorAll('.dm-dialog-cancel'));
|
|
677
|
+
const answer = E.confirm(html, options);
|
|
678
|
+
const cancel = [...document.querySelectorAll('.dm-dialog-cancel')].find(b => !before.has(b));
|
|
679
|
+
const dismiss = () => { try { cancel?.click(); } catch { /* gone */ } };
|
|
680
|
+
const stop = lifetime.add(dismiss);
|
|
681
|
+
try {
|
|
682
|
+
return Boolean(await answer);
|
|
683
|
+
} finally {
|
|
684
|
+
if (typeof stop === 'function') stop();
|
|
685
|
+
setTimeout(releaseBelow, 0);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Hold Escape on every panel open right now (see holdEscapeOn).
|
|
691
|
+
*
|
|
692
|
+
* @returns {() => void} release them all
|
|
693
|
+
*/
|
|
694
|
+
function holdEscapeBelow() {
|
|
695
|
+
const releases = [...openPanels].map(panel => holdEscapeOn(panel?.options ? panel : null));
|
|
696
|
+
return () => releases.forEach(release => release());
|
|
697
|
+
}
|
|
698
|
+
|
|
557
699
|
const onBeforeUnload = (event) => {
|
|
558
700
|
if ([...composeHandles].some(h => h.dirty?.())) { event.preventDefault(); event.returnValue = ''; }
|
|
559
701
|
};
|
|
@@ -671,6 +813,31 @@ return {
|
|
|
671
813
|
Domma.icons.scan(el);
|
|
672
814
|
}
|
|
673
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Wait for `work` with a skeleton at the foot of `host` - core's
|
|
818
|
+
* whileLoading (nothing for a quick answer, no flicker, gone on an
|
|
819
|
+
* error, which is rethrown). The slot is a plain BLOCK made for the
|
|
820
|
+
* occasion and taken out after: a flex row would collapse the
|
|
821
|
+
* skeleton to a sliver. An admin without the helper just waits.
|
|
822
|
+
*
|
|
823
|
+
* @param {HTMLElement} host
|
|
824
|
+
* @param {Function} work - returns a promise
|
|
825
|
+
* @param {string} [shape] - a key of core's SHAPES
|
|
826
|
+
* @returns {Promise<*>}
|
|
827
|
+
*/
|
|
828
|
+
async function withSkeleton(host, work, shape = 'rows') {
|
|
829
|
+
if (!host || typeof loading?.whileLoading !== 'function') return work();
|
|
830
|
+
const slot = document.createElement('div');
|
|
831
|
+
slot.className = 'mail-loading-slot';
|
|
832
|
+
slot.textContent = 'Loading\u2026';
|
|
833
|
+
host.appendChild(slot);
|
|
834
|
+
try {
|
|
835
|
+
return await loading.whileLoading(slot, work, loading.SHAPES?.[shape] ?? loading.SHAPES?.rows);
|
|
836
|
+
} finally {
|
|
837
|
+
slot.remove();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
674
841
|
/**
|
|
675
842
|
* Surface an error in the right place: a missing mailbox or an
|
|
676
843
|
* unopenable password are setup problems, everything else is a toast.
|
|
@@ -756,15 +923,43 @@ return {
|
|
|
756
923
|
* @returns {void}
|
|
757
924
|
*/
|
|
758
925
|
function openSettings() {
|
|
926
|
+
// One at a time: a second click, or a needs-password error while
|
|
927
|
+
// it is open, must not stack a second panel on the first.
|
|
928
|
+
if (!settingsOnce.claim()) return;
|
|
929
|
+
let panel = null;
|
|
930
|
+
try {
|
|
931
|
+
buildSettings((so) => { panel = so; });
|
|
932
|
+
} catch (err) {
|
|
933
|
+
// Failed before it opened: let go of everything it took.
|
|
934
|
+
panel?.__mailAbandon?.();
|
|
935
|
+
settingsOnce.release();
|
|
936
|
+
E.toast(err.message || 'Mail settings could not be opened.', {type: 'error'});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** @returns {void} */
|
|
941
|
+
function buildSettings(made = () => {}) {
|
|
759
942
|
// Help popovers per drawn section, let go when the panel closes.
|
|
760
943
|
const helpDisposers = [];
|
|
761
|
-
//
|
|
762
|
-
|
|
944
|
+
// What sections registered through ctx.guard / ctx.onClose.
|
|
945
|
+
const guards = new Set();
|
|
946
|
+
const closeFns = new Set();
|
|
947
|
+
const dirtyGuards = () => [...guards].filter(g => { try { return Boolean(g.dirty?.()); } catch { return false; } });
|
|
948
|
+
// Each section saves on its own button; the panel asks only for
|
|
949
|
+
// sections that said (ctx.guard) they hold unsaved work.
|
|
763
950
|
const slideover = guardPanel(E.slideover({title: 'Mail Settings', size: 'lg', position: 'right'}),
|
|
764
|
-
() =>
|
|
765
|
-
|
|
951
|
+
() => dirtyGuards().length > 0,
|
|
952
|
+
() => settingsDiscardQuestion(dirtyGuards().map(g => g.label)),
|
|
953
|
+
() => {
|
|
954
|
+
settingsOnce.release();
|
|
955
|
+
try { stopSettingsResize(); } catch { /* never made: the build failed first */ }
|
|
766
956
|
helpDisposers.splice(0).forEach(fn => { try { fn?.(); } catch { /* gone */ } });
|
|
767
|
-
|
|
957
|
+
for (const fn of [...closeFns]) { try { fn(); } catch { /* one failing must not stop the rest */ } }
|
|
958
|
+
closeFns.clear();
|
|
959
|
+
guards.clear();
|
|
960
|
+
},
|
|
961
|
+
{onDiscard: () => { for (const g of dirtyGuards()) { try { g.discard?.(); } catch { /* gone */ } } }});
|
|
962
|
+
made(slideover);
|
|
768
963
|
// Settings has tables of mailboxes and folder lists in it, so the
|
|
769
964
|
// width someone wants depends on their screen - the same reason
|
|
770
965
|
// compose is resizable. Its own remembered width, because the two
|
|
@@ -835,6 +1030,32 @@ return {
|
|
|
835
1030
|
assetVersion,
|
|
836
1031
|
field,
|
|
837
1032
|
close: () => slideover.close(),
|
|
1033
|
+
/**
|
|
1034
|
+
* Ask before the panel closes over this section's unsaved
|
|
1035
|
+
* work. One question names every section that has some;
|
|
1036
|
+
* OK runs each one's `discard`. A forced close (the screen
|
|
1037
|
+
* being left) never asks - keep a draft for that.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {{label: string, dirty: Function, discard?: Function}} guard
|
|
1040
|
+
* @returns {Function} remove it
|
|
1041
|
+
*/
|
|
1042
|
+
guard(guard) {
|
|
1043
|
+
if (!guard || typeof guard.dirty !== 'function') return () => {};
|
|
1044
|
+
guards.add(guard);
|
|
1045
|
+
return () => guards.delete(guard);
|
|
1046
|
+
},
|
|
1047
|
+
/**
|
|
1048
|
+
* Run `fn` once when the settings panel closes, however it
|
|
1049
|
+
* closes (and when the screen is left).
|
|
1050
|
+
*
|
|
1051
|
+
* @param {Function} fn
|
|
1052
|
+
* @returns {Function} cancel it
|
|
1053
|
+
*/
|
|
1054
|
+
onClose(fn) {
|
|
1055
|
+
if (typeof fn !== 'function') return () => {};
|
|
1056
|
+
closeFns.add(fn);
|
|
1057
|
+
return () => closeFns.delete(fn);
|
|
1058
|
+
},
|
|
838
1059
|
/**
|
|
839
1060
|
* @param {{reload?: boolean}} [options]
|
|
840
1061
|
* @returns {Promise<void>}
|
|
@@ -937,33 +1158,188 @@ return {
|
|
|
937
1158
|
/**
|
|
938
1159
|
* The mailbox list: what the free edition's settings consist of.
|
|
939
1160
|
*
|
|
1161
|
+
* One menu for the rows - right-click a row, its ⋮, or Shift+F10 on
|
|
1162
|
+
* it - and Enter on a row edits it, the row's main action. Registered
|
|
1163
|
+
* on the section rather than the rows, because the rows are redrawn
|
|
1164
|
+
* whenever the default changes.
|
|
1165
|
+
*
|
|
940
1166
|
* @param {HTMLElement} container
|
|
1167
|
+
* @param {object} [ctx] - the section context
|
|
941
1168
|
* @returns {void}
|
|
942
1169
|
*/
|
|
943
|
-
function renderMailboxSection(container) {
|
|
944
|
-
|
|
1170
|
+
function renderMailboxSection(container, ctx = null) {
|
|
1171
|
+
let body = mailboxSectionBody(redraw);
|
|
1172
|
+
container.appendChild(body);
|
|
1173
|
+
|
|
1174
|
+
/** Rebuild the rows, keeping focus on the same mailbox where it can. */
|
|
1175
|
+
function redraw() {
|
|
1176
|
+
const focused = document.activeElement?.closest?.('.mail-box-row')?.dataset.id ?? null;
|
|
1177
|
+
const next = mailboxSectionBody(redraw);
|
|
1178
|
+
body.replaceWith(next);
|
|
1179
|
+
body = next;
|
|
1180
|
+
Domma.icons.scan(body);
|
|
1181
|
+
if (focused) body.querySelector(`.mail-box-row[data-id="${CSS.escape(focused)}"]`)?.focus();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const rowOf = (el) => {
|
|
1185
|
+
const row = el?.closest?.('.mail-box-row');
|
|
1186
|
+
return row && container.contains(row) ? row : null;
|
|
1187
|
+
};
|
|
1188
|
+
const boxOf = (row) => mailboxes.find(b => b.id === row?.dataset.id) ?? null;
|
|
1189
|
+
|
|
1190
|
+
const elements = (typeof E !== 'undefined' && E?.contextMenu) ? E
|
|
1191
|
+
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
1192
|
+
let menu = null;
|
|
1193
|
+
if (typeof elements?.contextMenu === 'function') {
|
|
1194
|
+
menu = elements.contextMenu(container, {
|
|
1195
|
+
match: '.mail-box-row',
|
|
1196
|
+
inherit: false,
|
|
1197
|
+
items: [],
|
|
1198
|
+
onBeforeOpen: (c) => {
|
|
1199
|
+
const box = boxOf(rowOf(c?.target));
|
|
1200
|
+
if (!box) return false;
|
|
1201
|
+
menu.setItems(mailboxRowItems(box, redraw));
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
const onKey = (event) => {
|
|
1207
|
+
const row = rowOf(event.target);
|
|
1208
|
+
if (!row) return;
|
|
1209
|
+
if (event.key === 'F10' && event.shiftKey) {
|
|
1210
|
+
event.preventDefault();
|
|
1211
|
+
openMenuAt(row.querySelector('.mail-row-more') ?? row);
|
|
1212
|
+
} else if (event.key === 'Enter' && event.target === row) {
|
|
1213
|
+
event.preventDefault();
|
|
1214
|
+
const box = boxOf(row);
|
|
1215
|
+
if (box) openMailboxForm(box);
|
|
1216
|
+
} else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && event.target === row) {
|
|
1217
|
+
const rows = [...container.querySelectorAll('.mail-box-row')];
|
|
1218
|
+
const next = rows[rows.indexOf(row) + (event.key === 'ArrowDown' ? 1 : -1)];
|
|
1219
|
+
if (next) { event.preventDefault(); next.focus(); }
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
container.addEventListener('keydown', onKey);
|
|
1223
|
+
// Saved, removed or connected elsewhere (the form, the folder
|
|
1224
|
+
// pane's menu): the rows follow.
|
|
1225
|
+
mailboxRedraws.add(redraw);
|
|
1226
|
+
ctx?.onClose?.(() => {
|
|
1227
|
+
mailboxRedraws.delete(redraw);
|
|
1228
|
+
container.removeEventListener('keydown', onKey);
|
|
1229
|
+
try { menu?.destroy?.(); } catch { /* gone */ }
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Mailbox sections on screen, redrawn when the list changes. */
|
|
1234
|
+
const mailboxRedraws = new Set();
|
|
1235
|
+
/** The mailbox list changed: redraw whichever Settings rows are showing. */
|
|
1236
|
+
function mailboxesChanged() {
|
|
1237
|
+
for (const redraw of [...mailboxRedraws]) { try { redraw(); } catch { /* gone */ } }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* One mailbox row's menu.
|
|
1242
|
+
*
|
|
1243
|
+
* @param {object} box
|
|
1244
|
+
* @param {Function} redraw
|
|
1245
|
+
* @returns {object[]}
|
|
1246
|
+
*/
|
|
1247
|
+
function mailboxRowItems(box, redraw) {
|
|
1248
|
+
return menuItems(mailboxRowMenuModel({isDefault: box.isDefault === true, count: mailboxes.length}), {
|
|
1249
|
+
edit: () => openMailboxForm(box),
|
|
1250
|
+
open: () => openMailboxInReader(box),
|
|
1251
|
+
default: () => toggleDefaultMailbox(box, redraw),
|
|
1252
|
+
remove: () => removeMailbox(box, redraw)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Show a mailbox in the reader, closing Settings to do it.
|
|
1258
|
+
*
|
|
1259
|
+
* @param {object} box
|
|
1260
|
+
* @returns {Promise<void>}
|
|
1261
|
+
*/
|
|
1262
|
+
async function openMailboxInReader(box) {
|
|
1263
|
+
for (const panel of [...openPanels]) { try { await panel.close(); } catch { /* gone */ } }
|
|
1264
|
+
if (!here()) return;
|
|
1265
|
+
if (box.id !== currentAccountId) await selectAccount(box.id);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Make a mailbox the default, or stop it being one.
|
|
1270
|
+
*
|
|
1271
|
+
* @param {object} box
|
|
1272
|
+
* @param {Function} [redraw]
|
|
1273
|
+
* @returns {Promise<void>}
|
|
1274
|
+
*/
|
|
1275
|
+
async function toggleDefaultMailbox(box, redraw = () => {}) {
|
|
1276
|
+
try {
|
|
1277
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}/default`, 'PUT',
|
|
1278
|
+
{isDefault: !box.isDefault});
|
|
1279
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1280
|
+
if (!here()) return;
|
|
1281
|
+
mailboxes = list;
|
|
1282
|
+
renderFolders();
|
|
1283
|
+
redraw();
|
|
1284
|
+
mailboxesChanged();
|
|
1285
|
+
E.toast(box.isDefault ? 'Default cleared.' : `"${box.label}" is now the default.`,
|
|
1286
|
+
{type: 'success'});
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
if (here()) E.toast(err.message, {type: 'error'});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Forget a mailbox, after asking.
|
|
1294
|
+
*
|
|
1295
|
+
* @param {object} box
|
|
1296
|
+
* @param {Function} [redraw]
|
|
1297
|
+
* @returns {Promise<void>}
|
|
1298
|
+
*/
|
|
1299
|
+
async function removeMailbox(box, redraw = () => {}) {
|
|
1300
|
+
// The label is the person's own text, and E.confirm renders HTML.
|
|
1301
|
+
const ok = await ask(esc(`Remove "${box.label}"? The stored password is deleted, and so is anything unsaved in its form.`),
|
|
1302
|
+
{title: 'Remove mailbox', confirmText: 'Remove', cancelText: 'Keep it'});
|
|
1303
|
+
if (!ok || !here()) return;
|
|
1304
|
+
try {
|
|
1305
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'DELETE');
|
|
1306
|
+
writeMailboxDraft(box.id, null);
|
|
1307
|
+
E.toast('Mailbox removed.', {type: 'success'});
|
|
1308
|
+
refreshBadge();
|
|
1309
|
+
if (currentAccountId === box.id) currentAccountId = null;
|
|
1310
|
+
await start();
|
|
1311
|
+
if (here()) { redraw(); mailboxesChanged(); }
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
if (here()) E.toast(err.message, {type: 'error'});
|
|
1314
|
+
}
|
|
945
1315
|
}
|
|
946
1316
|
|
|
947
1317
|
/**
|
|
948
1318
|
* The mailbox rows and the Add button, rebuilt whole when the
|
|
949
|
-
* default changes so the order matches the folder pane.
|
|
1319
|
+
* default changes so the order matches the folder pane. Each row is a
|
|
1320
|
+
* card a keyboard can reach, with its own ⋮.
|
|
950
1321
|
*
|
|
1322
|
+
* @param {Function} redraw
|
|
951
1323
|
* @returns {HTMLElement}
|
|
952
1324
|
*/
|
|
953
|
-
function mailboxSectionBody() {
|
|
1325
|
+
function mailboxSectionBody(redraw) {
|
|
954
1326
|
const body = document.createElement('div');
|
|
955
1327
|
body.className = 'flex flex-col gap-2';
|
|
956
1328
|
|
|
957
1329
|
if (!mailboxes.length) {
|
|
958
1330
|
const empty = document.createElement('p');
|
|
959
1331
|
empty.className = 'text-sm text-muted mail-empty-line';
|
|
960
|
-
empty.textContent = 'No mailboxes yet.';
|
|
1332
|
+
empty.textContent = 'No mailboxes yet. Add one to read its mail here.';
|
|
961
1333
|
body.appendChild(empty);
|
|
962
1334
|
}
|
|
963
1335
|
|
|
964
1336
|
for (const box of mailboxes) {
|
|
965
1337
|
const row = document.createElement('div');
|
|
966
1338
|
row.className = 'mail-box-row';
|
|
1339
|
+
row.dataset.id = box.id;
|
|
1340
|
+
row.tabIndex = 0;
|
|
1341
|
+
row.setAttribute('aria-label', `${box.label} - Enter to edit, Shift+F10 for actions`);
|
|
1342
|
+
row.title = 'Right-click, ⋮ or Shift+F10 for actions';
|
|
967
1343
|
|
|
968
1344
|
const info = document.createElement('div');
|
|
969
1345
|
info.className = 'flex-1 mail-ellipsis';
|
|
@@ -976,12 +1352,18 @@ return {
|
|
|
976
1352
|
badge.textContent = 'Default';
|
|
977
1353
|
name.appendChild(badge);
|
|
978
1354
|
}
|
|
1355
|
+
if (box.id === currentAccountId && mailboxes.length > 1) {
|
|
1356
|
+
const open = document.createElement('span');
|
|
1357
|
+
open.className = 'badge badge-secondary mail-default-badge';
|
|
1358
|
+
open.textContent = 'Open';
|
|
1359
|
+
name.appendChild(open);
|
|
1360
|
+
}
|
|
979
1361
|
const sub = document.createElement('div');
|
|
980
1362
|
sub.className = 'text-xs text-muted mail-box-sub';
|
|
981
1363
|
const signIn = describeSignIn?.(box) ?? null;
|
|
982
1364
|
sub.textContent = signIn
|
|
983
|
-
? `${box.username}
|
|
984
|
-
: `${box.username}
|
|
1365
|
+
? `${box.username} · ${box.oauth.provider === 'google' ? 'Google' : 'Microsoft'} sign-in`
|
|
1366
|
+
: `${box.username} · ${box.host}:${box.port}${box.secure ? '' : ' (no TLS)'}`;
|
|
985
1367
|
if (signIn && signIn.tone !== 'ok') {
|
|
986
1368
|
const chip = document.createElement('span');
|
|
987
1369
|
chip.className = `badge mail-oauth-chip mail-oauth-chip--${signIn.tone}`;
|
|
@@ -989,78 +1371,187 @@ return {
|
|
|
989
1371
|
chip.title = signIn.text;
|
|
990
1372
|
name.appendChild(chip);
|
|
991
1373
|
}
|
|
1374
|
+
if (readMailboxDraft(box.id)) {
|
|
1375
|
+
const chip = document.createElement('span');
|
|
1376
|
+
chip.className = 'badge badge-warning mail-default-badge';
|
|
1377
|
+
chip.textContent = 'Unsaved changes';
|
|
1378
|
+
chip.title = 'You left changes to this mailbox unsaved - Edit to pick them up or throw them away';
|
|
1379
|
+
name.appendChild(chip);
|
|
1380
|
+
}
|
|
992
1381
|
info.appendChild(name);
|
|
993
1382
|
info.appendChild(sub);
|
|
994
1383
|
row.appendChild(info);
|
|
995
1384
|
|
|
996
1385
|
const editBtn = document.createElement('button');
|
|
1386
|
+
editBtn.type = 'button';
|
|
997
1387
|
editBtn.className = 'btn btn-sm btn-ghost';
|
|
998
1388
|
editBtn.textContent = 'Edit';
|
|
999
1389
|
editBtn.addEventListener('click', () => openMailboxForm(box));
|
|
1390
|
+
row.appendChild(editBtn);
|
|
1000
1391
|
|
|
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
|
-
}
|
|
1392
|
+
const more = document.createElement('button');
|
|
1393
|
+
more.type = 'button';
|
|
1394
|
+
more.className = 'btn btn-sm btn-ghost mail-row-more';
|
|
1395
|
+
more.title = 'More actions';
|
|
1396
|
+
more.setAttribute('aria-label', 'More actions');
|
|
1397
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
1398
|
+
const glyph = document.createElement('span');
|
|
1399
|
+
glyph.setAttribute('data-icon', 'more-vertical');
|
|
1400
|
+
glyph.setAttribute('data-icon-size', '14');
|
|
1401
|
+
more.appendChild(glyph);
|
|
1402
|
+
more.addEventListener('click', () => openMenuAt(more));
|
|
1403
|
+
row.appendChild(more);
|
|
1041
1404
|
|
|
1042
|
-
row.appendChild(editBtn);
|
|
1043
|
-
row.appendChild(removeBtn);
|
|
1044
1405
|
body.appendChild(row);
|
|
1045
1406
|
}
|
|
1046
1407
|
|
|
1047
1408
|
const addBtn = document.createElement('button');
|
|
1409
|
+
addBtn.type = 'button';
|
|
1048
1410
|
addBtn.className = 'btn btn-sm btn-primary';
|
|
1049
1411
|
addBtn.classList.add('mail-add-btn');
|
|
1050
|
-
addBtn.textContent = 'Add mailbox';
|
|
1412
|
+
addBtn.textContent = readMailboxDraft(null) ? 'Add mailbox (unsaved changes)' : 'Add mailbox';
|
|
1051
1413
|
addBtn.addEventListener('click', () => openMailboxForm(null));
|
|
1052
1414
|
body.appendChild(addBtn);
|
|
1415
|
+
Domma.icons.scan(body);
|
|
1053
1416
|
|
|
1054
1417
|
return body;
|
|
1055
1418
|
}
|
|
1056
1419
|
|
|
1420
|
+
// ---- The mailbox form's kept draft --------------------------------
|
|
1421
|
+
//
|
|
1422
|
+
// Label, server, port, TLS and username - NEVER the password, which
|
|
1423
|
+
// is the one field a draft in browser storage must not hold. Kept
|
|
1424
|
+
// while the form differs from what it opened with, removed when it is
|
|
1425
|
+
// saved or the person agrees to throw it away, and KEPT when the
|
|
1426
|
+
// screen is left with the form open.
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* This signed-in user's drafts key - per user, since browser storage
|
|
1430
|
+
* is shared by whoever signs in here. Null (no drafts) without one.
|
|
1431
|
+
*
|
|
1432
|
+
* @returns {string|null}
|
|
1433
|
+
*/
|
|
1434
|
+
function draftsKey() {
|
|
1435
|
+
try { return mailboxDraftsKey(S.get('auth_user')?.id); } catch { return null; }
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** @returns {Object.<string, object>} */
|
|
1439
|
+
function readMailboxDrafts() {
|
|
1440
|
+
const key = draftsKey();
|
|
1441
|
+
if (!key) return {};
|
|
1442
|
+
try { return normaliseDraftMap(S.get(key)); } catch { return {}; }
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* @param {string|null} boxId
|
|
1447
|
+
* @returns {object|null}
|
|
1448
|
+
*/
|
|
1449
|
+
function readMailboxDraft(boxId) {
|
|
1450
|
+
return readMailboxDrafts()[mailboxDraftId(boxId)] ?? null;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Write one draft (null removes it), read-modify-write on a fresh
|
|
1455
|
+
* copy of the map - nothing holds a copy that could be stale.
|
|
1456
|
+
*
|
|
1457
|
+
* @param {string|null} boxId
|
|
1458
|
+
* @param {object|null} draft
|
|
1459
|
+
* @returns {void}
|
|
1460
|
+
*/
|
|
1461
|
+
function writeMailboxDraft(boxId, draft) {
|
|
1462
|
+
try {
|
|
1463
|
+
const map = readMailboxDrafts();
|
|
1464
|
+
const id = mailboxDraftId(boxId);
|
|
1465
|
+
if (draft) map[id] = draft;
|
|
1466
|
+
else delete map[id];
|
|
1467
|
+
const key = draftsKey();
|
|
1468
|
+
if (!key) return;
|
|
1469
|
+
if (Object.keys(map).length) S.set(key, map);
|
|
1470
|
+
else forgetDrafts();
|
|
1471
|
+
} catch { /* a convenience: the form still works */ }
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** No drafts left: take the key out rather than keep an empty map. */
|
|
1475
|
+
function forgetDrafts() {
|
|
1476
|
+
const key = draftsKey();
|
|
1477
|
+
if (!key) return;
|
|
1478
|
+
if (typeof S.remove === 'function') S.remove(key);
|
|
1479
|
+
else S.set(key, {});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** Drop the drafts of mailboxes that no longer exist. */
|
|
1483
|
+
function pruneMailboxDrafts() {
|
|
1484
|
+
try {
|
|
1485
|
+
const map = readMailboxDrafts();
|
|
1486
|
+
const kept = pruneDraftMap(map, mailboxes.map(b => b.id));
|
|
1487
|
+
if (Object.keys(kept).length !== Object.keys(map).length) {
|
|
1488
|
+
if (Object.keys(kept).length) S.set(draftsKey(), kept);
|
|
1489
|
+
else forgetDrafts();
|
|
1490
|
+
}
|
|
1491
|
+
} catch { /* ditto */ }
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Open the add/edit form for one mailbox - once at a time, after
|
|
1496
|
+
* asking about any changes left unsaved last time.
|
|
1497
|
+
*
|
|
1498
|
+
* @param {object|null} box - null to add
|
|
1499
|
+
* @returns {Promise<void>}
|
|
1500
|
+
*/
|
|
1501
|
+
async function openMailboxForm(box) {
|
|
1502
|
+
if (!mailboxFormOnce.claim()) return;
|
|
1503
|
+
let opened = false;
|
|
1504
|
+
try {
|
|
1505
|
+
// Edit the stored version, not the row's copy from whenever the
|
|
1506
|
+
// list was drawn - and judge a kept draft against it.
|
|
1507
|
+
if (box) {
|
|
1508
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1509
|
+
if (!here()) return;
|
|
1510
|
+
mailboxes = list;
|
|
1511
|
+
const fresh = list.find(b => b.id === box.id);
|
|
1512
|
+
if (!fresh) {
|
|
1513
|
+
// Removed since (another tab, another device). Its
|
|
1514
|
+
// unsaved changes have nowhere to go.
|
|
1515
|
+
writeMailboxDraft(box.id, null);
|
|
1516
|
+
E.toast(`"${box.label}" has been removed since, so it cannot be edited.`, {type: 'warning'});
|
|
1517
|
+
renderFolders();
|
|
1518
|
+
mailboxesChanged();
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
box = fresh;
|
|
1522
|
+
}
|
|
1523
|
+
const kept = readMailboxDraft(box?.id ?? null);
|
|
1524
|
+
const decision = mailboxDraftDecision(kept, box);
|
|
1525
|
+
if (decision.kind === 'drop') writeMailboxDraft(box?.id ?? null, null);
|
|
1526
|
+
let picked = null;
|
|
1527
|
+
if (decision.kind === 'ask') {
|
|
1528
|
+
// OK throws the changes away; Cancel, Escape and the
|
|
1529
|
+
// backdrop all keep them. The draft is removed only once
|
|
1530
|
+
// answered - leaving while asked keeps it for next time.
|
|
1531
|
+
const throwAway = await ask(esc(mailboxRestoreQuestion({
|
|
1532
|
+
label: box?.label, at: kept.at, changed: decision.changed, isNew: !box
|
|
1533
|
+
})), RESTORE_BUTTONS);
|
|
1534
|
+
if (!here()) return;
|
|
1535
|
+
if (throwAway) writeMailboxDraft(box?.id ?? null, null);
|
|
1536
|
+
else picked = kept;
|
|
1537
|
+
}
|
|
1538
|
+
buildMailboxForm(box, picked);
|
|
1539
|
+
opened = true;
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
if (here()) E.toast(err.message || 'That form could not be opened.', {type: 'error'});
|
|
1542
|
+
} finally {
|
|
1543
|
+
if (!opened) mailboxFormOnce.release();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1057
1547
|
/**
|
|
1058
|
-
*
|
|
1548
|
+
* Draw the add/edit form.
|
|
1059
1549
|
*
|
|
1060
1550
|
* @param {object|null} box - null to add
|
|
1551
|
+
* @param {{data: object, was: string}|null} restored - a kept draft picked up
|
|
1061
1552
|
* @returns {void}
|
|
1062
1553
|
*/
|
|
1063
|
-
function
|
|
1554
|
+
function buildMailboxForm(box, restored) {
|
|
1064
1555
|
const slideover = E.slideover({
|
|
1065
1556
|
title: box ? 'Edit Mailbox' : 'Add Mailbox',
|
|
1066
1557
|
size: 'md',
|
|
@@ -1070,6 +1561,9 @@ return {
|
|
|
1070
1561
|
storageKey: 'domma-mail.mailbox-form-width',
|
|
1071
1562
|
defaultWidth: 560
|
|
1072
1563
|
});
|
|
1564
|
+
const draftFor = box?.id ?? null;
|
|
1565
|
+
// NOT `start` - that is the view's own reload, called below.
|
|
1566
|
+
const initial = restored?.data ?? mailboxDraftData(box ?? {});
|
|
1073
1567
|
|
|
1074
1568
|
const form = document.createElement('div');
|
|
1075
1569
|
form.className = 'mail-form-stack';
|
|
@@ -1080,10 +1574,17 @@ return {
|
|
|
1080
1574
|
form.appendChild(pwWrap);
|
|
1081
1575
|
const signedIn = box?.auth === 'oauth';
|
|
1082
1576
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1577
|
+
if (restored) {
|
|
1578
|
+
const note = document.createElement('p');
|
|
1579
|
+
note.className = 'text-sm text-muted mail-restored-note';
|
|
1580
|
+
note.textContent = 'These are the changes you left unsaved. The password is never kept - type it again if you changed it.';
|
|
1581
|
+
pwWrap.appendChild(note);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const labelInput = field(pwWrap, 'label', 'Name', 'text', box || restored ? initial.label : '', 'What to call this mailbox.');
|
|
1585
|
+
const hostInput = field(pwWrap, 'host', 'IMAP server', 'text', box || restored ? initial.host : '', 'e.g. mail.example.com');
|
|
1586
|
+
const portInput = field(pwWrap, 'port', 'Port', 'number', String(box || restored ? initial.port : 993));
|
|
1587
|
+
const userInput = field(pwWrap, 'username', 'Username', 'text', box || restored ? initial.username : '');
|
|
1087
1588
|
const passInput = field(
|
|
1088
1589
|
pwWrap, 'password', 'Password', 'password', '',
|
|
1089
1590
|
box?.hasPassword ? 'Leave blank to keep the saved password.' : ''
|
|
@@ -1093,14 +1594,18 @@ return {
|
|
|
1093
1594
|
secureWrap.className = 'flex items-center gap-2 text-sm';
|
|
1094
1595
|
const secureInput = document.createElement('input');
|
|
1095
1596
|
secureInput.type = 'checkbox';
|
|
1096
|
-
secureInput.checked = box ?
|
|
1597
|
+
secureInput.checked = box || restored ? initial.secure !== false : true;
|
|
1097
1598
|
secureWrap.appendChild(secureInput);
|
|
1098
1599
|
secureWrap.appendChild(document.createTextNode('Use TLS (port 993)'));
|
|
1600
|
+
const tlsHelp = document.createElement('span');
|
|
1601
|
+
tlsHelp.setAttribute('data-help-title', 'TLS');
|
|
1602
|
+
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.');
|
|
1603
|
+
secureWrap.appendChild(tlsHelp);
|
|
1099
1604
|
pwWrap.appendChild(secureWrap);
|
|
1100
1605
|
|
|
1101
1606
|
// Keep the port in step with the TLS choice until the user sets one
|
|
1102
1607
|
// themselves - 993/143 is the only pair that matters here.
|
|
1103
|
-
let portTouched =
|
|
1608
|
+
let portTouched = Boolean(restored);
|
|
1104
1609
|
portInput.addEventListener('input', () => {
|
|
1105
1610
|
portTouched = true;
|
|
1106
1611
|
});
|
|
@@ -1112,7 +1617,7 @@ return {
|
|
|
1112
1617
|
status.className = 'text-sm mail-status';
|
|
1113
1618
|
|
|
1114
1619
|
/**
|
|
1115
|
-
* Say something under the
|
|
1620
|
+
* Say something under the form.
|
|
1116
1621
|
*
|
|
1117
1622
|
* A class rather than `style.color`, for the reason given
|
|
1118
1623
|
* throughout this file: an inline colour cannot read a `--dm-*`
|
|
@@ -1134,15 +1639,18 @@ return {
|
|
|
1134
1639
|
actions.className = 'flex justify-between gap-2 mt-2';
|
|
1135
1640
|
|
|
1136
1641
|
const testBtn = document.createElement('button');
|
|
1642
|
+
testBtn.type = 'button';
|
|
1137
1643
|
testBtn.className = 'btn btn-sm btn-ghost';
|
|
1138
1644
|
testBtn.textContent = 'Test connection';
|
|
1139
1645
|
|
|
1140
1646
|
const right = document.createElement('div');
|
|
1141
1647
|
right.className = 'flex gap-2';
|
|
1142
1648
|
const cancelBtn = document.createElement('button');
|
|
1649
|
+
cancelBtn.type = 'button';
|
|
1143
1650
|
cancelBtn.className = 'btn btn-sm btn-ghost';
|
|
1144
1651
|
cancelBtn.textContent = 'Cancel';
|
|
1145
1652
|
const saveBtn = document.createElement('button');
|
|
1653
|
+
saveBtn.type = 'button';
|
|
1146
1654
|
saveBtn.className = 'btn btn-sm btn-primary';
|
|
1147
1655
|
saveBtn.textContent = 'Save';
|
|
1148
1656
|
right.appendChild(cancelBtn);
|
|
@@ -1153,20 +1661,54 @@ return {
|
|
|
1153
1661
|
pwWrap.appendChild(actions);
|
|
1154
1662
|
|
|
1155
1663
|
slideover.element.appendChild(form);
|
|
1156
|
-
// What
|
|
1157
|
-
|
|
1664
|
+
// What "unchanged" means: the stored mailbox (or a blank new
|
|
1665
|
+
// one) - NOT a restored draft, which is unsaved by definition.
|
|
1666
|
+
const clean = JSON.stringify(mailboxDraftData(box ?? {}));
|
|
1667
|
+
// `was` - what the mailbox looked like when this editing began. A
|
|
1668
|
+
// picked-up draft keeps its own, so the next question still knows
|
|
1669
|
+
// the mailbox moved under it.
|
|
1670
|
+
const was = restored ? restored.was : mailboxFingerprint(box);
|
|
1671
|
+
const current = () => JSON.stringify(mailboxDraftData(collect()));
|
|
1158
1672
|
let signInPanel = null;
|
|
1159
1673
|
let stopChoiceHelp = null;
|
|
1674
|
+
let stopFormHelp = null;
|
|
1160
1675
|
let method = signedIn ? 'oauth' : 'password';
|
|
1676
|
+
let saving = false;
|
|
1677
|
+
let discarded = false;
|
|
1678
|
+
const formDirty = () => current() !== clean;
|
|
1679
|
+
|
|
1680
|
+
/** Keep the draft in step with the form: written while it differs, gone when it does not. */
|
|
1681
|
+
const touch = () => {
|
|
1682
|
+
if (discarded || saving || method !== 'password' || !here()) return;
|
|
1683
|
+
if (formDirty()) writeMailboxDraft(draftFor, {data: mailboxDraftData(collect()), was, at: Date.now()});
|
|
1684
|
+
else writeMailboxDraft(draftFor, null);
|
|
1685
|
+
};
|
|
1686
|
+
const onEdit = () => setTimeout(touch, 0);
|
|
1687
|
+
for (const type of ['input', 'change']) pwWrap.addEventListener(type, onEdit);
|
|
1688
|
+
|
|
1161
1689
|
guardPanel(slideover,
|
|
1162
|
-
() => (method === 'password'
|
|
1163
|
-
? passInput.value !== '' ||
|
|
1690
|
+
() => !saving && (method === 'password'
|
|
1691
|
+
? passInput.value !== '' || formDirty()
|
|
1164
1692
|
: Boolean(signInPanel?.dirty())),
|
|
1165
1693
|
box ? 'Discard your changes to this mailbox?' : 'Discard this mailbox? It has not been saved.',
|
|
1166
|
-
() => {
|
|
1694
|
+
() => {
|
|
1695
|
+
mailboxFormOnce.release();
|
|
1696
|
+
stopFormResize();
|
|
1697
|
+
signInPanel?.dispose();
|
|
1698
|
+
stopChoiceHelp?.();
|
|
1699
|
+
stopFormHelp?.();
|
|
1700
|
+
for (const type of ['input', 'change']) pwWrap.removeEventListener(type, onEdit);
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
// Agreed to lose them: the draft goes too. A forced close
|
|
1704
|
+
// (the screen being left) never gets here, so it keeps it.
|
|
1705
|
+
onDiscard: () => { discarded = true; writeMailboxDraft(draftFor, null); }
|
|
1706
|
+
});
|
|
1167
1707
|
slideover.open();
|
|
1708
|
+
stopFormHelp = helpPopovers(pwWrap, {position: 'bottom'});
|
|
1709
|
+
if (restored) touch();
|
|
1168
1710
|
if (signedIn) pwWrap.hidden = true;
|
|
1169
|
-
else setTimeout(() => hostInput.focus(), 50);
|
|
1711
|
+
else setTimeout(() => (box ? labelInput : hostInput).focus(), 50);
|
|
1170
1712
|
|
|
1171
1713
|
/**
|
|
1172
1714
|
* Offer "Sign in with Microsoft / Google" when this edition has it
|
|
@@ -1177,6 +1719,7 @@ return {
|
|
|
1177
1719
|
const oauth = features.oauth && mountSignIn
|
|
1178
1720
|
? await api(`${BASE}/oauth/status`).catch(() => null)
|
|
1179
1721
|
: null;
|
|
1722
|
+
if (!here() || !slideover.element?.isConnected) return;
|
|
1180
1723
|
if (!oauth?.available) {
|
|
1181
1724
|
if (signedIn) {
|
|
1182
1725
|
const note = document.createElement('p');
|
|
@@ -1203,14 +1746,23 @@ return {
|
|
|
1203
1746
|
signInPanel = mountSignIn(signInHost, {
|
|
1204
1747
|
provider, box, base: BASE, api, status: oauth, helpPopovers,
|
|
1205
1748
|
onConnected: async (accountId) => {
|
|
1749
|
+
discarded = true;
|
|
1750
|
+
writeMailboxDraft(draftFor, null);
|
|
1206
1751
|
slideover.close(true);
|
|
1207
1752
|
E.toast('Mailbox connected.', {type: 'success'});
|
|
1753
|
+
refreshBadge();
|
|
1754
|
+
if (!here()) return;
|
|
1208
1755
|
if (accountId) currentAccountId = accountId;
|
|
1209
1756
|
await start();
|
|
1757
|
+
if (here()) mailboxesChanged();
|
|
1210
1758
|
},
|
|
1211
1759
|
onChanged: async () => {
|
|
1212
|
-
|
|
1760
|
+
const list = (await api(`${BASE}/accounts`)).accounts;
|
|
1761
|
+
if (!here()) return;
|
|
1762
|
+
mailboxes = list;
|
|
1213
1763
|
renderFolders();
|
|
1764
|
+
mailboxesChanged();
|
|
1765
|
+
refreshBadge();
|
|
1214
1766
|
},
|
|
1215
1767
|
onCancel: () => slideover.close()
|
|
1216
1768
|
});
|
|
@@ -1275,9 +1827,10 @@ return {
|
|
|
1275
1827
|
|
|
1276
1828
|
testBtn.addEventListener('click', async () => {
|
|
1277
1829
|
testBtn.disabled = true;
|
|
1278
|
-
say('Connecting
|
|
1830
|
+
say('Connecting…');
|
|
1279
1831
|
try {
|
|
1280
1832
|
const result = await api(`${BASE}/accounts/test`, 'POST', collect());
|
|
1833
|
+
if (!slideover.element?.isConnected) return;
|
|
1281
1834
|
say(
|
|
1282
1835
|
result.ok ? 'Connected successfully.' : result.error,
|
|
1283
1836
|
result.ok ? 'ok' : 'bad'
|
|
@@ -1292,19 +1845,36 @@ return {
|
|
|
1292
1845
|
cancelBtn.addEventListener('click', () => slideover.close());
|
|
1293
1846
|
|
|
1294
1847
|
saveBtn.addEventListener('click', async () => {
|
|
1848
|
+
if (saving) return;
|
|
1849
|
+
// The panel closes once saved, so nothing typed while the
|
|
1850
|
+
// request runs could be kept: the form is locked until it
|
|
1851
|
+
// answers, and unlocked again if it fails.
|
|
1852
|
+
saving = true;
|
|
1295
1853
|
saveBtn.disabled = true;
|
|
1854
|
+
form.inert = true;
|
|
1855
|
+
form.setAttribute('aria-busy', 'true');
|
|
1856
|
+
say('Saving…');
|
|
1296
1857
|
try {
|
|
1297
1858
|
const result = box
|
|
1298
1859
|
? await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'PUT', collect())
|
|
1299
1860
|
: await api(`${BASE}/accounts`, 'POST', collect());
|
|
1861
|
+
discarded = true;
|
|
1862
|
+
writeMailboxDraft(draftFor, null);
|
|
1300
1863
|
slideover.close(true);
|
|
1301
1864
|
E.toast('Mailbox saved.', {type: 'success'});
|
|
1865
|
+
refreshBadge();
|
|
1866
|
+
if (!here()) return;
|
|
1302
1867
|
currentAccountId = result.account.id;
|
|
1303
1868
|
await start();
|
|
1869
|
+
if (here()) mailboxesChanged();
|
|
1304
1870
|
} catch (err) {
|
|
1305
1871
|
say(err.message, 'bad');
|
|
1306
|
-
|
|
1872
|
+
saving = false;
|
|
1307
1873
|
saveBtn.disabled = false;
|
|
1874
|
+
form.inert = false;
|
|
1875
|
+
form.removeAttribute('aria-busy');
|
|
1876
|
+
// Kept for next time, as it stood when Save was pressed.
|
|
1877
|
+
touch();
|
|
1308
1878
|
}
|
|
1309
1879
|
});
|
|
1310
1880
|
}
|
|
@@ -1320,6 +1890,7 @@ return {
|
|
|
1320
1890
|
*/
|
|
1321
1891
|
function renderFolders() {
|
|
1322
1892
|
foldersEl.textContent = '';
|
|
1893
|
+
paintUnreadFilter();
|
|
1323
1894
|
|
|
1324
1895
|
for (const box of mailboxes) {
|
|
1325
1896
|
const isCurrent = box.id === currentAccountId;
|
|
@@ -1340,8 +1911,19 @@ return {
|
|
|
1340
1911
|
name.textContent = box.label;
|
|
1341
1912
|
name.title = `${box.username} · ${box.host}`;
|
|
1342
1913
|
accountBtn.appendChild(name);
|
|
1343
|
-
|
|
1344
|
-
accountBtn.
|
|
1914
|
+
accountBtn.dataset.account = box.id;
|
|
1915
|
+
accountBtn.title = `${box.username} \u00b7 click to open \u00b7 right-click, \u22ee or Shift+F10 for actions`;
|
|
1916
|
+
accountBtn.appendChild(rowMore());
|
|
1917
|
+
|
|
1918
|
+
accountBtn.addEventListener('click', (event) => {
|
|
1919
|
+
const more = event.target.closest('.mail-row-more');
|
|
1920
|
+
if (more) {
|
|
1921
|
+
event.stopPropagation();
|
|
1922
|
+
openMenuAt(more);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
selectAccount(box.id);
|
|
1926
|
+
});
|
|
1345
1927
|
foldersEl.appendChild(accountBtn);
|
|
1346
1928
|
}
|
|
1347
1929
|
|
|
@@ -1398,8 +1980,8 @@ return {
|
|
|
1398
1980
|
// the same leaf name apart - and doubles as the hint.
|
|
1399
1981
|
name.title = folder.path;
|
|
1400
1982
|
btn.title = features.folders
|
|
1401
|
-
? `${folder.path} - right-click to rename, delete or add a folder inside`
|
|
1402
|
-
: folder.path
|
|
1983
|
+
? `${folder.path} - right-click, \u22ee or Shift+F10 to rename, delete or add a folder inside`
|
|
1984
|
+
: `${folder.path} - right-click, \u22ee or Shift+F10 for more`;
|
|
1403
1985
|
btn.appendChild(name);
|
|
1404
1986
|
|
|
1405
1987
|
if (folder.contextOnly) btn.classList.add('is-context');
|
|
@@ -1420,6 +2002,7 @@ return {
|
|
|
1420
2002
|
: `${count} unread`;
|
|
1421
2003
|
btn.appendChild(pill);
|
|
1422
2004
|
}
|
|
2005
|
+
btn.appendChild(rowMore());
|
|
1423
2006
|
|
|
1424
2007
|
// One handler, two gestures: the chevron folds, the rest of
|
|
1425
2008
|
// the row opens the folder. A nested <button> inside a
|
|
@@ -1429,6 +2012,12 @@ return {
|
|
|
1429
2012
|
// click opens the folder, which redraws this pane, and a
|
|
1430
2013
|
// dblclick needs both clicks to land on the same element.
|
|
1431
2014
|
btn.addEventListener('click', event => {
|
|
2015
|
+
const more = event.target.closest('.mail-row-more');
|
|
2016
|
+
if (more) {
|
|
2017
|
+
event.stopPropagation();
|
|
2018
|
+
openMenuAt(more);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
1432
2021
|
const onToggle = !!event.target.closest('.mail-folder-toggle');
|
|
1433
2022
|
if (event.detail >= 2) {
|
|
1434
2023
|
// The first click already folded it when it hit
|
|
@@ -1458,6 +2047,39 @@ return {
|
|
|
1458
2047
|
Domma.icons.scan(foldersEl);
|
|
1459
2048
|
}
|
|
1460
2049
|
|
|
2050
|
+
/**
|
|
2051
|
+
* The Unread filter says how many it would show: the unread in this
|
|
2052
|
+
* mailbox's folders, counted from the listing already in hand.
|
|
2053
|
+
*
|
|
2054
|
+
* @returns {void}
|
|
2055
|
+
*/
|
|
2056
|
+
function paintUnreadFilter() {
|
|
2057
|
+
const label = unreadFilterEl?.querySelector('span');
|
|
2058
|
+
if (!label) return;
|
|
2059
|
+
const unread = folders.reduce((n, f) => n + (Number.isFinite(f.unseen) ? f.unseen : 0), 0);
|
|
2060
|
+
label.textContent = unread ? `Unread (${unread > 999 ? '999+' : unread})` : 'Unread';
|
|
2061
|
+
unreadFilterEl.title = unread
|
|
2062
|
+
? `Show only the folders holding the ${unread} unread message${unread === 1 ? '' : 's'}`
|
|
2063
|
+
: 'Show only folders with unread mail';
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* The ⋮ on a folder-pane row. A span with the button role, not a
|
|
2068
|
+
* <button>: the row IS a button, and the click target decides - the
|
|
2069
|
+
* same resolution as the message list's ⋮ and the fold chevron.
|
|
2070
|
+
*
|
|
2071
|
+
* @returns {HTMLElement}
|
|
2072
|
+
*/
|
|
2073
|
+
function rowMore() {
|
|
2074
|
+
const more = document.createElement('span');
|
|
2075
|
+
more.className = 'mail-row-more mail-folder-more';
|
|
2076
|
+
more.setAttribute('role', 'button');
|
|
2077
|
+
more.setAttribute('aria-label', 'Actions');
|
|
2078
|
+
more.title = 'Actions';
|
|
2079
|
+
more.textContent = '\u22ee';
|
|
2080
|
+
return more;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1461
2083
|
/**
|
|
1462
2084
|
* Remember which mailbox is being looked at.
|
|
1463
2085
|
*
|
|
@@ -1573,7 +2195,13 @@ return {
|
|
|
1573
2195
|
}
|
|
1574
2196
|
if (!here() || currentAccountId !== account) return;
|
|
1575
2197
|
page = 1;
|
|
1576
|
-
await loadMessages({fresh: true});
|
|
2198
|
+
await loadMessages({fresh: true, quiet: true});
|
|
2199
|
+
if (!here()) return;
|
|
2200
|
+
// A refresh is when the unread badge is most likely to have
|
|
2201
|
+
// moved - new mail, or mail read on the phone. The read routes
|
|
2202
|
+
// forget their cached badge on a fresh read, so this gets a
|
|
2203
|
+
// new number rather than the one from a minute ago.
|
|
2204
|
+
refreshBadge();
|
|
1577
2205
|
} catch (err) {
|
|
1578
2206
|
handleError(err);
|
|
1579
2207
|
} finally {
|
|
@@ -1581,12 +2209,60 @@ return {
|
|
|
1581
2209
|
}
|
|
1582
2210
|
}
|
|
1583
2211
|
|
|
2212
|
+
/**
|
|
2213
|
+
* Bumped by every list load, so an answer that lands after a newer
|
|
2214
|
+
* request was made (a quick second folder click) is dropped rather
|
|
2215
|
+
* than drawn over the newer one.
|
|
2216
|
+
*/
|
|
2217
|
+
let listSeq = 0;
|
|
2218
|
+
/** Is this load still the one that matters, on a screen still here? */
|
|
2219
|
+
const listCurrent = (seq) => here() && seq === listSeq;
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* Run a list request. A NEW list (another folder, page or search)
|
|
2223
|
+
* empties the pane and shows a skeleton while it loads; a quiet one
|
|
2224
|
+
* (a refresh, the list after an undo or a send) keeps what is on
|
|
2225
|
+
* screen until the answer replaces it.
|
|
2226
|
+
*
|
|
2227
|
+
* @param {Function} work
|
|
2228
|
+
* @param {boolean} quiet
|
|
2229
|
+
* @returns {Promise<*>}
|
|
2230
|
+
*/
|
|
2231
|
+
function listLoad(work, quiet) {
|
|
2232
|
+
if (quiet) return work();
|
|
2233
|
+
disposeRowEffects();
|
|
2234
|
+
listEl.textContent = '';
|
|
2235
|
+
pagerEl.textContent = '';
|
|
2236
|
+
return withSkeleton(listEl, work, 'rows');
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/**
|
|
2240
|
+
* A list load failed. Said once, and only for the load that is still
|
|
2241
|
+
* current; a new list that could not be fetched says so in the pane
|
|
2242
|
+
* rather than leaving it blank.
|
|
2243
|
+
*
|
|
2244
|
+
* @param {Error} err
|
|
2245
|
+
* @param {number} seq
|
|
2246
|
+
* @param {boolean} quiet
|
|
2247
|
+
* @returns {void}
|
|
2248
|
+
*/
|
|
2249
|
+
function listFailed(err, seq, quiet) {
|
|
2250
|
+
if (!listCurrent(seq)) return;
|
|
2251
|
+
handleError(err);
|
|
2252
|
+
if (!quiet && !err.needsSetup && !listEl.childElementCount) {
|
|
2253
|
+
showEmpty(listEl, 'alert-triangle', 'This list could not be loaded.', {
|
|
2254
|
+
label: 'Try again',
|
|
2255
|
+
onClick: () => loadMessages()
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
1584
2260
|
/** Fetch and draw the current page. */
|
|
1585
|
-
async function loadMessages({fresh = false} = {}) {
|
|
1586
|
-
if (searchQuery) return runSearch();
|
|
1587
|
-
if (threadsOn) return loadThreads({fresh});
|
|
2261
|
+
async function loadMessages({fresh = false, quiet = false} = {}) {
|
|
2262
|
+
if (searchQuery) return runSearch({quiet});
|
|
2263
|
+
if (threadsOn) return loadThreads({fresh, quiet});
|
|
1588
2264
|
|
|
1589
|
-
const
|
|
2265
|
+
const seq = ++listSeq;
|
|
1590
2266
|
try {
|
|
1591
2267
|
const params = new URLSearchParams({
|
|
1592
2268
|
account: currentAccountId ?? '',
|
|
@@ -1595,7 +2271,8 @@ return {
|
|
|
1595
2271
|
limit: String(pageSize)
|
|
1596
2272
|
});
|
|
1597
2273
|
if (fresh) params.set('fresh', '1');
|
|
1598
|
-
const result = await api(`${BASE}/messages?${params}`);
|
|
2274
|
+
const result = await listLoad(() => api(`${BASE}/messages?${params}`), quiet);
|
|
2275
|
+
if (!listCurrent(seq)) return;
|
|
1599
2276
|
lastListSource = result.source ?? 'imap';
|
|
1600
2277
|
messages = result.messages;
|
|
1601
2278
|
total = result.total;
|
|
@@ -1603,9 +2280,7 @@ return {
|
|
|
1603
2280
|
renderList();
|
|
1604
2281
|
renderPager();
|
|
1605
2282
|
} catch (err) {
|
|
1606
|
-
|
|
1607
|
-
} finally {
|
|
1608
|
-
loader.destroy();
|
|
2283
|
+
listFailed(err, seq, quiet);
|
|
1609
2284
|
}
|
|
1610
2285
|
}
|
|
1611
2286
|
|
|
@@ -1620,8 +2295,8 @@ return {
|
|
|
1620
2295
|
*
|
|
1621
2296
|
* @returns {Promise<void>}
|
|
1622
2297
|
*/
|
|
1623
|
-
async function loadThreads({fresh = false} = {}) {
|
|
1624
|
-
const
|
|
2298
|
+
async function loadThreads({fresh = false, quiet = false} = {}) {
|
|
2299
|
+
const seq = ++listSeq;
|
|
1625
2300
|
try {
|
|
1626
2301
|
const params = new URLSearchParams({
|
|
1627
2302
|
account: currentAccountId ?? '',
|
|
@@ -1629,16 +2304,22 @@ return {
|
|
|
1629
2304
|
page: String(page),
|
|
1630
2305
|
limit: String(pageSize)
|
|
1631
2306
|
});
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2307
|
+
// Both requests inside the one load, so the skeleton covers
|
|
2308
|
+
// the fallback too.
|
|
2309
|
+
const {result, flat} = await listLoad(async () => {
|
|
2310
|
+
const grouped = await api(`${BASE}/threads?${params}`);
|
|
2311
|
+
if (grouped.mirrored) return {result: grouped, flat: null};
|
|
1636
2312
|
// The flat list, without turning the preference off: the
|
|
1637
2313
|
// next folder may well be mirrored, and silently
|
|
1638
2314
|
// forgetting the setting because one folder was not is
|
|
1639
2315
|
// worse than one line of explanation.
|
|
1640
2316
|
if (fresh) params.set('fresh', '1');
|
|
1641
|
-
|
|
2317
|
+
return {result: grouped, flat: await api(`${BASE}/messages?${params}`)};
|
|
2318
|
+
}, quiet);
|
|
2319
|
+
if (!listCurrent(seq)) return;
|
|
2320
|
+
|
|
2321
|
+
if (!result.mirrored) {
|
|
2322
|
+
note('This folder is not mirrored, so it cannot be grouped into conversations.');
|
|
1642
2323
|
lastListSource = flat.source ?? 'imap';
|
|
1643
2324
|
messages = flat.messages;
|
|
1644
2325
|
total = flat.total;
|
|
@@ -1655,9 +2336,7 @@ return {
|
|
|
1655
2336
|
renderList();
|
|
1656
2337
|
renderPager();
|
|
1657
2338
|
} catch (err) {
|
|
1658
|
-
|
|
1659
|
-
} finally {
|
|
1660
|
-
loader.destroy();
|
|
2339
|
+
listFailed(err, seq, quiet);
|
|
1661
2340
|
}
|
|
1662
2341
|
}
|
|
1663
2342
|
|
|
@@ -1746,8 +2425,8 @@ return {
|
|
|
1746
2425
|
*
|
|
1747
2426
|
* @returns {Promise<void>}
|
|
1748
2427
|
*/
|
|
1749
|
-
async function runSearch() {
|
|
1750
|
-
const
|
|
2428
|
+
async function runSearch({quiet = false} = {}) {
|
|
2429
|
+
const seq = ++listSeq;
|
|
1751
2430
|
try {
|
|
1752
2431
|
const params = new URLSearchParams({
|
|
1753
2432
|
account: currentAccountId ?? '',
|
|
@@ -1755,7 +2434,8 @@ return {
|
|
|
1755
2434
|
page: String(page),
|
|
1756
2435
|
limit: String(pageSize)
|
|
1757
2436
|
});
|
|
1758
|
-
const result = await api(`${BASE}/search?${params}`);
|
|
2437
|
+
const result = await listLoad(() => api(`${BASE}/search?${params}`), quiet);
|
|
2438
|
+
if (!listCurrent(seq)) return;
|
|
1759
2439
|
|
|
1760
2440
|
messages = result.messages;
|
|
1761
2441
|
total = result.total;
|
|
@@ -1777,9 +2457,7 @@ return {
|
|
|
1777
2457
|
note(`${result.total} result${result.total === 1 ? '' : 's'}. ${scope}`);
|
|
1778
2458
|
}
|
|
1779
2459
|
} catch (err) {
|
|
1780
|
-
|
|
1781
|
-
} finally {
|
|
1782
|
-
loader.destroy();
|
|
2460
|
+
listFailed(err, seq, quiet);
|
|
1783
2461
|
}
|
|
1784
2462
|
}
|
|
1785
2463
|
|
|
@@ -2244,7 +2922,7 @@ return {
|
|
|
2244
2922
|
? async () => {
|
|
2245
2923
|
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
2246
2924
|
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
2247
|
-
await loadMessages();
|
|
2925
|
+
await loadMessages({quiet: true});
|
|
2248
2926
|
}
|
|
2249
2927
|
: null
|
|
2250
2928
|
);
|
|
@@ -2257,7 +2935,8 @@ return {
|
|
|
2257
2935
|
*/
|
|
2258
2936
|
async function bulkDelete() {
|
|
2259
2937
|
const uids = [...selected];
|
|
2260
|
-
if (!await
|
|
2938
|
+
if (!await ask(`Delete ${uids.length} message${uids.length === 1 ? '' : 's'}?`,
|
|
2939
|
+
{confirmText: 'Delete', cancelText: 'Keep them'}) || !here()) return;
|
|
2261
2940
|
|
|
2262
2941
|
const source = currentFolder;
|
|
2263
2942
|
const params = new URLSearchParams({account: currentAccountId ?? '', folder: source});
|
|
@@ -2276,7 +2955,7 @@ return {
|
|
|
2276
2955
|
? async () => {
|
|
2277
2956
|
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
2278
2957
|
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
2279
|
-
await loadMessages();
|
|
2958
|
+
await loadMessages({quiet: true});
|
|
2280
2959
|
}
|
|
2281
2960
|
: null
|
|
2282
2961
|
);
|
|
@@ -2367,10 +3046,15 @@ return {
|
|
|
2367
3046
|
*/
|
|
2368
3047
|
function clearReader() {
|
|
2369
3048
|
cancelMarkRead();
|
|
3049
|
+
// A message still on its way must not land in the cleared pane.
|
|
3050
|
+
openSeq += 1;
|
|
2370
3051
|
readerHeadEl.textContent = '';
|
|
2371
3052
|
showEmpty(readerBodyEl, 'mail', 'Select a message to read it.');
|
|
2372
3053
|
}
|
|
2373
3054
|
|
|
3055
|
+
/** Bumped by every openMessage, so a slow answer cannot replace a newer one. */
|
|
3056
|
+
let openSeq = 0;
|
|
3057
|
+
|
|
2374
3058
|
/**
|
|
2375
3059
|
* Fetch and display one message.
|
|
2376
3060
|
*
|
|
@@ -2397,6 +3081,10 @@ return {
|
|
|
2397
3081
|
// resolve against the folder the message is actually in.
|
|
2398
3082
|
openFolder = folder ?? currentFolder;
|
|
2399
3083
|
|
|
3084
|
+
// Arrowing quickly through a list opens several in turn; only the
|
|
3085
|
+
// last one asked for may draw, and nothing draws once the screen
|
|
3086
|
+
// has gone.
|
|
3087
|
+
const seq = ++openSeq;
|
|
2400
3088
|
const loader = E.loader(readerBodyEl, {type: 'dots'});
|
|
2401
3089
|
try {
|
|
2402
3090
|
const params = new URLSearchParams({
|
|
@@ -2405,6 +3093,7 @@ return {
|
|
|
2405
3093
|
});
|
|
2406
3094
|
if (imagesAllowed.has(uid)) params.set('images', 'load');
|
|
2407
3095
|
const message = await api(`${BASE}/messages/${uid}?${params}`);
|
|
3096
|
+
if (!here() || seq !== openSeq) return;
|
|
2408
3097
|
renderMessage(message);
|
|
2409
3098
|
|
|
2410
3099
|
// The reading preference controls how long mail stays open
|
|
@@ -2426,7 +3115,7 @@ return {
|
|
|
2426
3115
|
}, readDelaySeconds(S.get(READ_DELAY_KEY)));
|
|
2427
3116
|
}
|
|
2428
3117
|
} catch (err) {
|
|
2429
|
-
handleError(err);
|
|
3118
|
+
if (here() && seq === openSeq) handleError(err);
|
|
2430
3119
|
} finally {
|
|
2431
3120
|
loader.destroy();
|
|
2432
3121
|
}
|
|
@@ -2741,6 +3430,8 @@ return {
|
|
|
2741
3430
|
searchInputEl?.focus();
|
|
2742
3431
|
break;
|
|
2743
3432
|
case 'escape':
|
|
3433
|
+
// A panel on top owns Escape: it closes that, and only that.
|
|
3434
|
+
if (openPanels.size) break;
|
|
2744
3435
|
if (selected.size) clearSelection();
|
|
2745
3436
|
else if (searchQuery) clearSearch();
|
|
2746
3437
|
break;
|
|
@@ -2807,7 +3498,9 @@ return {
|
|
|
2807
3498
|
* @returns {void}
|
|
2808
3499
|
*/
|
|
2809
3500
|
function showShortcuts() {
|
|
2810
|
-
|
|
3501
|
+
if (!shortcutsOnce.claim()) return;
|
|
3502
|
+
const modal = guardPanel(E.slideover({title: 'Keyboard shortcuts', size: 'sm', position: 'right'}),
|
|
3503
|
+
() => false, undefined, () => shortcutsOnce.release());
|
|
2811
3504
|
const list = document.createElement('div');
|
|
2812
3505
|
list.className = 'mail-kv';
|
|
2813
3506
|
for (const [keys, what] of SHORTCUTS) {
|
|
@@ -3290,7 +3983,7 @@ return {
|
|
|
3290
3983
|
mailboxAddress: profile?.mailboxAddress ?? '',
|
|
3291
3984
|
templates: profile?.templates ?? [],
|
|
3292
3985
|
undoSeconds: profile?.undoSendSeconds ?? 0,
|
|
3293
|
-
onSent: () => loadMessages(),
|
|
3986
|
+
onSent: () => loadMessages({quiet: true}),
|
|
3294
3987
|
onQueued: (result) => offerRecall(result),
|
|
3295
3988
|
...extra
|
|
3296
3989
|
};
|
|
@@ -3386,7 +4079,15 @@ return {
|
|
|
3386
4079
|
* @returns {Promise<void>}
|
|
3387
4080
|
*/
|
|
3388
4081
|
async function showOutbox() {
|
|
3389
|
-
|
|
4082
|
+
if (!outboxOnce.claim()) return;
|
|
4083
|
+
let slideover;
|
|
4084
|
+
try {
|
|
4085
|
+
slideover = guardPanel(E.slideover({title: 'Waiting', size: 'md', position: 'right'}),
|
|
4086
|
+
() => false, undefined, () => outboxOnce.release());
|
|
4087
|
+
} catch (err) {
|
|
4088
|
+
outboxOnce.release();
|
|
4089
|
+
throw err;
|
|
4090
|
+
}
|
|
3390
4091
|
const host = document.createElement('div');
|
|
3391
4092
|
host.className = 'mail-panel-stack';
|
|
3392
4093
|
slideover.element.appendChild(host);
|
|
@@ -3395,13 +4096,17 @@ return {
|
|
|
3395
4096
|
/**
|
|
3396
4097
|
* @returns {Promise<void>}
|
|
3397
4098
|
*/
|
|
3398
|
-
async function draw() {
|
|
3399
|
-
host.textContent = '';
|
|
4099
|
+
async function draw(first = false) {
|
|
3400
4100
|
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
3401
|
-
const
|
|
4101
|
+
const fetchBoth = () => Promise.all([
|
|
3402
4102
|
api(`${BASE}/outbox?${params}`).catch(() => ({queued: []})),
|
|
3403
4103
|
api(`${BASE}/snoozed?${params}`).catch(() => ({snoozed: []}))
|
|
3404
4104
|
]);
|
|
4105
|
+
// A skeleton the first time; a redraw keeps the rows on
|
|
4106
|
+
// screen until the new ones are in.
|
|
4107
|
+
const [outbox, snoozed] = first ? await withSkeleton(host, fetchBoth, 'rows') : await fetchBoth();
|
|
4108
|
+
if (!here() || !slideover.element?.isConnected) return;
|
|
4109
|
+
host.textContent = '';
|
|
3405
4110
|
|
|
3406
4111
|
host.appendChild(section('Waiting to send', (outbox.queued ?? []).map(row => ({
|
|
3407
4112
|
title: row.subject || '(no subject)',
|
|
@@ -3447,7 +4152,7 @@ return {
|
|
|
3447
4152
|
await api(`${BASE}/snoozed/${row.id}/wake`, 'POST');
|
|
3448
4153
|
await draw();
|
|
3449
4154
|
refreshOutbox();
|
|
3450
|
-
await loadMessages();
|
|
4155
|
+
await loadMessages({quiet: true});
|
|
3451
4156
|
}]
|
|
3452
4157
|
]
|
|
3453
4158
|
})), 'Nothing is snoozed.'));
|
|
@@ -3512,7 +4217,7 @@ return {
|
|
|
3512
4217
|
return wrap;
|
|
3513
4218
|
}
|
|
3514
4219
|
|
|
3515
|
-
await draw();
|
|
4220
|
+
await draw(true);
|
|
3516
4221
|
}
|
|
3517
4222
|
|
|
3518
4223
|
// ---------------------------------------------------------------
|
|
@@ -3530,7 +4235,7 @@ return {
|
|
|
3530
4235
|
*/
|
|
3531
4236
|
function askForTime(title) {
|
|
3532
4237
|
return new Promise((resolve) => {
|
|
3533
|
-
const modal = E.modal({title, size: 'sm'});
|
|
4238
|
+
const modal = trackModal(E.modal({title, size: 'sm'}));
|
|
3534
4239
|
let answered = false;
|
|
3535
4240
|
|
|
3536
4241
|
/**
|
|
@@ -3596,8 +4301,14 @@ return {
|
|
|
3596
4301
|
* @returns {Promise<void>}
|
|
3597
4302
|
*/
|
|
3598
4303
|
async function snooze(message) {
|
|
3599
|
-
|
|
3600
|
-
|
|
4304
|
+
if (!dialogOnce.claim()) return;
|
|
4305
|
+
let at;
|
|
4306
|
+
try {
|
|
4307
|
+
at = await askForTime('Snooze until');
|
|
4308
|
+
} finally {
|
|
4309
|
+
dialogOnce.release();
|
|
4310
|
+
}
|
|
4311
|
+
if (!at || !here()) return;
|
|
3601
4312
|
|
|
3602
4313
|
try {
|
|
3603
4314
|
const params = new URLSearchParams({
|
|
@@ -3617,7 +4328,7 @@ return {
|
|
|
3617
4328
|
// already moved, so there is no "leave it where it was".
|
|
3618
4329
|
async () => {
|
|
3619
4330
|
await api(`${BASE}/snoozed/${result.id}/wake`, 'POST');
|
|
3620
|
-
await loadMessages();
|
|
4331
|
+
await loadMessages({quiet: true});
|
|
3621
4332
|
}
|
|
3622
4333
|
);
|
|
3623
4334
|
refreshOutbox();
|
|
@@ -3638,7 +4349,9 @@ return {
|
|
|
3638
4349
|
* @returns {Promise<void>}
|
|
3639
4350
|
*/
|
|
3640
4351
|
async function unsubscribe(message) {
|
|
3641
|
-
|
|
4352
|
+
// The subject is a stranger's text, and E.confirm renders HTML.
|
|
4353
|
+
if (!await ask(esc(`Unsubscribe from the list "${message.subject || 'this message'}" came from?`),
|
|
4354
|
+
{confirmText: 'Unsubscribe', cancelText: 'Stay subscribed'}) || !here()) return;
|
|
3642
4355
|
|
|
3643
4356
|
try {
|
|
3644
4357
|
const params = new URLSearchParams({
|
|
@@ -3720,7 +4433,8 @@ return {
|
|
|
3720
4433
|
E.toast(`Created the draft page ${result.urlPath}.`, {type: 'success'});
|
|
3721
4434
|
// Offered rather than taken: leaving the mail view loses
|
|
3722
4435
|
// whatever was half-read in it.
|
|
3723
|
-
if (await
|
|
4436
|
+
if (await ask(esc(`Open "${result.title}" in the page editor now?`),
|
|
4437
|
+
{confirmText: 'Open it', cancelText: 'Not now'}) && here()) {
|
|
3724
4438
|
location.hash = `#/pages/edit${result.urlPath}`;
|
|
3725
4439
|
}
|
|
3726
4440
|
} catch (err) {
|
|
@@ -3739,13 +4453,30 @@ return {
|
|
|
3739
4453
|
* @returns {Promise<void>}
|
|
3740
4454
|
*/
|
|
3741
4455
|
async function messageToEntry(message) {
|
|
4456
|
+
// One at a time; released when the dialog closes, or below if
|
|
4457
|
+
// it never opens.
|
|
4458
|
+
if (!dialogOnce.claim()) return;
|
|
4459
|
+
let shown = false;
|
|
4460
|
+
try {
|
|
4461
|
+
shown = await fileAsEntry(message);
|
|
4462
|
+
} finally {
|
|
4463
|
+
if (!shown) dialogOnce.release();
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
/**
|
|
4468
|
+
* @param {object} message
|
|
4469
|
+
* @returns {Promise<boolean>} whether the dialog opened
|
|
4470
|
+
*/
|
|
4471
|
+
async function fileAsEntry(message) {
|
|
3742
4472
|
let available;
|
|
3743
4473
|
try {
|
|
3744
4474
|
available = await api(`${BASE}/collections`);
|
|
3745
4475
|
} catch (err) {
|
|
3746
|
-
E.toast(err.message || 'The collections could not be listed.', {type: 'error'});
|
|
3747
|
-
return;
|
|
4476
|
+
if (here()) E.toast(err.message || 'The collections could not be listed.', {type: 'error'});
|
|
4477
|
+
return false;
|
|
3748
4478
|
}
|
|
4479
|
+
if (!here()) return false;
|
|
3749
4480
|
|
|
3750
4481
|
// A collection whose required fields a message cannot fill is not
|
|
3751
4482
|
// usable, however good the rest of the mapping looks - choosing
|
|
@@ -3762,10 +4493,10 @@ return {
|
|
|
3762
4493
|
: 'No collection has fields a message could be filed into.',
|
|
3763
4494
|
{type: 'warning'}
|
|
3764
4495
|
);
|
|
3765
|
-
return;
|
|
4496
|
+
return false;
|
|
3766
4497
|
}
|
|
3767
4498
|
|
|
3768
|
-
const modal = E.modal({title: 'File as a collection entry', size: 'sm'});
|
|
4499
|
+
const modal = trackModal(E.modal({title: 'File as a collection entry', size: 'sm'}), () => dialogOnce.release());
|
|
3769
4500
|
const panel = document.createElement('div');
|
|
3770
4501
|
panel.className = 'mail-dialog-stack';
|
|
3771
4502
|
|
|
@@ -3835,6 +4566,7 @@ return {
|
|
|
3835
4566
|
|
|
3836
4567
|
modal.element.appendChild(panel);
|
|
3837
4568
|
modal.open();
|
|
4569
|
+
return true;
|
|
3838
4570
|
}
|
|
3839
4571
|
|
|
3840
4572
|
/**
|
|
@@ -4068,7 +4800,7 @@ return {
|
|
|
4068
4800
|
});
|
|
4069
4801
|
await api(`${BASE}/messages/move?${back}`, 'POST',
|
|
4070
4802
|
{uids: mapped, destination: from});
|
|
4071
|
-
await loadMessages();
|
|
4803
|
+
await loadMessages({quiet: true});
|
|
4072
4804
|
}
|
|
4073
4805
|
: null
|
|
4074
4806
|
);
|
|
@@ -4077,6 +4809,184 @@ return {
|
|
|
4077
4809
|
}
|
|
4078
4810
|
}
|
|
4079
4811
|
|
|
4812
|
+
// ---------------------------------------------------------------
|
|
4813
|
+
// Edition extensions (createReaderView's `extensions` option)
|
|
4814
|
+
// ---------------------------------------------------------------
|
|
4815
|
+
|
|
4816
|
+
/**
|
|
4817
|
+
* What an extension is handed: enough to open a panel of its own
|
|
4818
|
+
* against this mailbox and to tell the reader something changed.
|
|
4819
|
+
* Read through getters, so a handle kept by an open panel still sees
|
|
4820
|
+
* the current mailbox and folders.
|
|
4821
|
+
*
|
|
4822
|
+
* @type {object}
|
|
4823
|
+
*/
|
|
4824
|
+
const extensionView = Object.freeze({
|
|
4825
|
+
pluginName,
|
|
4826
|
+
base: BASE,
|
|
4827
|
+
api,
|
|
4828
|
+
assetVersion,
|
|
4829
|
+
/** The mailbox on screen (a mailbox row's menu passes its own). */
|
|
4830
|
+
get account() { return mailboxes.find(b => b.id === currentAccountId) ?? null; },
|
|
4831
|
+
get mailboxes() { return [...mailboxes]; },
|
|
4832
|
+
get folders() { return [...folders]; },
|
|
4833
|
+
get currentFolder() { return currentFolder; },
|
|
4834
|
+
/** False once the screen has gone - check it after every await. */
|
|
4835
|
+
here,
|
|
4836
|
+
/**
|
|
4837
|
+
* Run `fn` once when the screen goes (or is mounted again).
|
|
4838
|
+
*
|
|
4839
|
+
* @param {Function} fn
|
|
4840
|
+
* @returns {Function} cancel it
|
|
4841
|
+
*/
|
|
4842
|
+
onLeave(fn) {
|
|
4843
|
+
if (typeof fn !== 'function') return () => {};
|
|
4844
|
+
let live = true;
|
|
4845
|
+
lifetime.add(() => { if (live) { live = false; fn(); } });
|
|
4846
|
+
return () => { live = false; };
|
|
4847
|
+
},
|
|
4848
|
+
/**
|
|
4849
|
+
* Have the reader look after a slideover the extension opened:
|
|
4850
|
+
* Escape belongs to it while it is on top (the reader's own
|
|
4851
|
+
* Escape - clear the selection, the search - does not also
|
|
4852
|
+
* answer), it is closed when the screen goes, and it is taken
|
|
4853
|
+
* out of the page once closed. Call before `open()`.
|
|
4854
|
+
*
|
|
4855
|
+
* @param {object} slideover - from E.slideover
|
|
4856
|
+
* @param {{onClosed?: Function}} [options]
|
|
4857
|
+
* @returns {object} the slideover
|
|
4858
|
+
*/
|
|
4859
|
+
panel(slideover, {onClosed} = {}) {
|
|
4860
|
+
return guardPanel(slideover, () => false, '', typeof onClosed === 'function' ? onClosed : () => {});
|
|
4861
|
+
},
|
|
4862
|
+
/** Read the folder list again, staying in the folder that is open. */
|
|
4863
|
+
refreshFolders: () => refreshFolders(),
|
|
4864
|
+
/**
|
|
4865
|
+
* The same as a settings section's `changed`, so a panel written
|
|
4866
|
+
* for one works from the other: reload means the folders (and the
|
|
4867
|
+
* open list) of the mailbox on screen.
|
|
4868
|
+
*
|
|
4869
|
+
* @param {{reload?: boolean}} [options]
|
|
4870
|
+
* @returns {Promise<void>}
|
|
4871
|
+
*/
|
|
4872
|
+
async changed({reload = false} = {}) {
|
|
4873
|
+
profileFor = null;
|
|
4874
|
+
refreshOutbox();
|
|
4875
|
+
if (reload) await refreshFolders();
|
|
4876
|
+
}
|
|
4877
|
+
});
|
|
4878
|
+
|
|
4879
|
+
/**
|
|
4880
|
+
* The folder list again, keeping the open folder when it still
|
|
4881
|
+
* exists (a full loadFolders goes back to the inbox), and the open
|
|
4882
|
+
* list with it - an import into the folder on screen shows at once.
|
|
4883
|
+
*
|
|
4884
|
+
* @returns {Promise<void>}
|
|
4885
|
+
*/
|
|
4886
|
+
async function refreshFolders() {
|
|
4887
|
+
if (!here() || !currentAccountId) return;
|
|
4888
|
+
const account = currentAccountId;
|
|
4889
|
+
const seq = ++foldersSeq;
|
|
4890
|
+
try {
|
|
4891
|
+
const params = new URLSearchParams({account, counts: '1'});
|
|
4892
|
+
const result = await api(`${BASE}/folders?${params}`);
|
|
4893
|
+
if (!here() || seq !== foldersSeq || currentAccountId !== account) return;
|
|
4894
|
+
if (!result.folders?.some(f => f.path === currentFolder)) {
|
|
4895
|
+
await loadFolders({quiet: true});
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
folders = result.folders;
|
|
4899
|
+
renderFolders();
|
|
4900
|
+
if (!searchQuery) await loadMessages({fresh: true, quiet: true});
|
|
4901
|
+
if (here()) refreshBadge();
|
|
4902
|
+
} catch (err) {
|
|
4903
|
+
if (here()) handleError(err);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
/**
|
|
4908
|
+
* Call one of the edition's extension functions, surviving one that
|
|
4909
|
+
* is missing, throws or returns rubbish.
|
|
4910
|
+
*
|
|
4911
|
+
* @param {string} name
|
|
4912
|
+
* @param {...*} args
|
|
4913
|
+
* @returns {*}
|
|
4914
|
+
*/
|
|
4915
|
+
function callExtension(name, ...args) {
|
|
4916
|
+
const fn = extensions?.[name];
|
|
4917
|
+
if (typeof fn !== 'function') return [];
|
|
4918
|
+
try {
|
|
4919
|
+
return fn(...args, extensionView) ?? [];
|
|
4920
|
+
} catch (err) {
|
|
4921
|
+
console.warn(`[mail] extensions.${name} failed:`, err);
|
|
4922
|
+
return [];
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
/**
|
|
4927
|
+
* An extension's action, run on the next task - a menu item picked
|
|
4928
|
+
* with Enter that opens a dialog must not hand it that same Enter -
|
|
4929
|
+
* and only while the screen is still here, with a throw shown.
|
|
4930
|
+
*
|
|
4931
|
+
* @param {Function} fn
|
|
4932
|
+
* @returns {Function}
|
|
4933
|
+
*/
|
|
4934
|
+
function deferExtension(fn) {
|
|
4935
|
+
return (...args) => {
|
|
4936
|
+
setTimeout(() => {
|
|
4937
|
+
if (!here()) return;
|
|
4938
|
+
Promise.resolve()
|
|
4939
|
+
.then(() => fn(...args))
|
|
4940
|
+
.catch(err => E.toast(err?.message || 'That could not be done.', {type: 'error'}));
|
|
4941
|
+
}, 0);
|
|
4942
|
+
};
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4945
|
+
/**
|
|
4946
|
+
* The menu items an extension adds for one row.
|
|
4947
|
+
*
|
|
4948
|
+
* @param {'folderItems'|'mailboxItems'} name
|
|
4949
|
+
* @param {object} subject - the folder or mailbox
|
|
4950
|
+
* @returns {object[]}
|
|
4951
|
+
*/
|
|
4952
|
+
function extensionItems(name, subject) {
|
|
4953
|
+
return extensionMenuItems(callExtension(name, subject), deferExtension);
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
/**
|
|
4957
|
+
* The extension's buttons in the Folders pane head, beside New folder.
|
|
4958
|
+
*
|
|
4959
|
+
* @returns {void}
|
|
4960
|
+
*/
|
|
4961
|
+
function addPaneActions() {
|
|
4962
|
+
const actions = extensionPaneActions(callExtension('paneActions'));
|
|
4963
|
+
if (!actions.length) return;
|
|
4964
|
+
const head = foldersEl?.closest('.mail-folders-pane')?.querySelector('.mail-pane-head');
|
|
4965
|
+
if (!head) return;
|
|
4966
|
+
const before = head.querySelector('#mail-settings-btn');
|
|
4967
|
+
for (const action of actions) {
|
|
4968
|
+
const id = `mail-ext-${action.id}`;
|
|
4969
|
+
if (head.querySelector(`#${id}`)) continue;
|
|
4970
|
+
const button = document.createElement('button');
|
|
4971
|
+
button.type = 'button';
|
|
4972
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
4973
|
+
button.id = id;
|
|
4974
|
+
button.title = action.label;
|
|
4975
|
+
button.setAttribute('aria-label', action.label);
|
|
4976
|
+
if (action.haspopup) button.setAttribute('aria-haspopup', action.haspopup);
|
|
4977
|
+
const icon = document.createElement('span');
|
|
4978
|
+
icon.setAttribute('data-icon', action.icon);
|
|
4979
|
+
icon.setAttribute('data-icon-size', '14');
|
|
4980
|
+
button.appendChild(icon);
|
|
4981
|
+
// A click needs no deferring, but it costs nothing and keeps
|
|
4982
|
+
// every entry point the same.
|
|
4983
|
+
button.addEventListener('click', deferExtension(() => action.onClick(extensionView)));
|
|
4984
|
+
if (before?.parentNode === head) head.insertBefore(button, before);
|
|
4985
|
+
else head.appendChild(button);
|
|
4986
|
+
}
|
|
4987
|
+
try { Domma.icons?.scan?.(head); } catch { /* the title still names it */ }
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4080
4990
|
/**
|
|
4081
4991
|
* Right-click a folder to manage it.
|
|
4082
4992
|
*
|
|
@@ -4093,12 +5003,21 @@ return {
|
|
|
4093
5003
|
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
4094
5004
|
if (typeof elements?.contextMenu !== 'function' || !foldersEl) return;
|
|
4095
5005
|
|
|
5006
|
+
// One registration for both kinds of row - the mailbox rows and
|
|
5007
|
+
// the folders - since they share the pane.
|
|
4096
5008
|
folderMenu = elements.contextMenu(foldersEl, {
|
|
4097
5009
|
cursor: 'glyph',
|
|
4098
|
-
match: '.mail-folder',
|
|
5010
|
+
match: '.mail-folder, .mail-account',
|
|
4099
5011
|
inherit: false,
|
|
4100
5012
|
items: [],
|
|
4101
5013
|
onBeforeOpen: (ctx) => {
|
|
5014
|
+
const account = ctx?.target?.closest?.('.mail-account');
|
|
5015
|
+
if (account) {
|
|
5016
|
+
const box = mailboxes.find(b => b.id === account.dataset.account);
|
|
5017
|
+
if (!box) return false;
|
|
5018
|
+
folderMenu.setItems(accountMenuItemsFor(box));
|
|
5019
|
+
return true;
|
|
5020
|
+
}
|
|
4102
5021
|
const row = ctx?.target?.closest?.('.mail-folder');
|
|
4103
5022
|
if (!row) return false;
|
|
4104
5023
|
const path = row.dataset.path;
|
|
@@ -4110,6 +5029,47 @@ return {
|
|
|
4110
5029
|
});
|
|
4111
5030
|
}
|
|
4112
5031
|
|
|
5032
|
+
/**
|
|
5033
|
+
* A mailbox row's menu, in the folder pane.
|
|
5034
|
+
*
|
|
5035
|
+
* @param {object} box
|
|
5036
|
+
* @returns {object[]}
|
|
5037
|
+
*/
|
|
5038
|
+
function accountMenuItemsFor(box) {
|
|
5039
|
+
return mergeExtensionItems(menuItems(accountMenuModel({
|
|
5040
|
+
isCurrent: box.id === currentAccountId,
|
|
5041
|
+
isDefault: box.isDefault === true,
|
|
5042
|
+
count: mailboxes.length
|
|
5043
|
+
}), {
|
|
5044
|
+
open: () => selectAccount(box.id),
|
|
5045
|
+
refresh: () => refreshMailbox(),
|
|
5046
|
+
edit: () => openMailboxForm(box),
|
|
5047
|
+
default: () => toggleDefaultMailbox(box),
|
|
5048
|
+
remove: () => removeMailbox(box)
|
|
5049
|
+
}), extensionItems('mailboxItems', box));
|
|
5050
|
+
}
|
|
5051
|
+
|
|
5052
|
+
/**
|
|
5053
|
+
* What every edition offers on a folder - open it, fold it, copy
|
|
5054
|
+
* its path. The free reader's whole menu; Pro adds management below.
|
|
5055
|
+
*
|
|
5056
|
+
* @param {object} folder - a node from the folder tree
|
|
5057
|
+
* @returns {object[]}
|
|
5058
|
+
*/
|
|
5059
|
+
function folderReadItems(folder) {
|
|
5060
|
+
const node = flattenFolderTree(buildFolderTree(folders), new Set()).find(n => n.path === folder.path) ?? folder;
|
|
5061
|
+
return menuItems(folderReadMenuModel({
|
|
5062
|
+
selectable: node.selectable !== false,
|
|
5063
|
+
hasChildren: (node.children ?? []).length > 0,
|
|
5064
|
+
collapsed: collapsedSet().has(folder.path),
|
|
5065
|
+
isCurrent: folder.path === currentFolder && !searchQuery
|
|
5066
|
+
}), {
|
|
5067
|
+
open: () => selectFolder(node),
|
|
5068
|
+
fold: () => toggleFolder(folder.path),
|
|
5069
|
+
copy: () => copyText(folder.path, 'Folder path copied.')
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
5072
|
+
|
|
4113
5073
|
/**
|
|
4114
5074
|
* The items for one folder.
|
|
4115
5075
|
*
|
|
@@ -4126,7 +5086,12 @@ return {
|
|
|
4126
5086
|
const isInbox = String(folder.path).toUpperCase() === 'INBOX';
|
|
4127
5087
|
const locked = protectedUse || isInbox;
|
|
4128
5088
|
|
|
4129
|
-
|
|
5089
|
+
const read = folderReadItems(folder);
|
|
5090
|
+
const extra = extensionItems('folderItems', folder);
|
|
5091
|
+
if (!features.folders) return mergeExtensionItems(read, extra);
|
|
5092
|
+
return mergeExtensionItems([
|
|
5093
|
+
...read,
|
|
5094
|
+
{type: 'divider'},
|
|
4130
5095
|
{
|
|
4131
5096
|
label: 'New folder',
|
|
4132
5097
|
icon: 'folder-plus',
|
|
@@ -4162,7 +5127,7 @@ return {
|
|
|
4162
5127
|
disabled: () => locked,
|
|
4163
5128
|
action: () => deleteFolder(folder)
|
|
4164
5129
|
}
|
|
4165
|
-
];
|
|
5130
|
+
], extra);
|
|
4166
5131
|
}
|
|
4167
5132
|
|
|
4168
5133
|
/**
|
|
@@ -4176,15 +5141,24 @@ return {
|
|
|
4176
5141
|
// infer a parent from, so the parent has to be part of the ask -
|
|
4177
5142
|
// otherwise every folder made that way lands at the top level and
|
|
4178
5143
|
// has to be moved afterwards.
|
|
4179
|
-
|
|
4180
|
-
if (!
|
|
5144
|
+
// One question at a time: a double click must not stack two.
|
|
5145
|
+
if (!dialogOnce.claim()) return;
|
|
5146
|
+
let chosen;
|
|
5147
|
+
try {
|
|
5148
|
+
chosen = pickParent ? await askForFolder(parent) : {parent, name: null};
|
|
5149
|
+
} finally {
|
|
5150
|
+
dialogOnce.release();
|
|
5151
|
+
}
|
|
5152
|
+
if (!chosen || !here()) return;
|
|
4181
5153
|
|
|
4182
|
-
const name = chosen.name ?? await
|
|
4183
|
-
chosen.parent
|
|
5154
|
+
const name = chosen.name ?? await promptWith(
|
|
5155
|
+
esc(chosen.parent
|
|
4184
5156
|
? `Name for the new folder inside ${chosen.parent}`
|
|
4185
|
-
: 'Name for the new folder'
|
|
5157
|
+
: 'Name for the new folder'),
|
|
5158
|
+
{title: 'New folder', confirmText: 'Create'},
|
|
5159
|
+
''
|
|
4186
5160
|
);
|
|
4187
|
-
if (!name || !String(name).trim()) return;
|
|
5161
|
+
if (!here() || !name || !String(name).trim()) return;
|
|
4188
5162
|
parent = chosen.parent;
|
|
4189
5163
|
|
|
4190
5164
|
try {
|
|
@@ -4192,7 +5166,7 @@ return {
|
|
|
4192
5166
|
const result = await api(`${BASE}/folders?${params}`, 'POST',
|
|
4193
5167
|
{name: String(name).trim(), parent: parent ?? ''});
|
|
4194
5168
|
E.toast(`Created ${result.path}.`, {type: 'success'});
|
|
4195
|
-
await loadFolders();
|
|
5169
|
+
await loadFolders({quiet: true});
|
|
4196
5170
|
} catch (err) {
|
|
4197
5171
|
E.toast(err.message || 'That folder could not be created.', {type: 'error'});
|
|
4198
5172
|
}
|
|
@@ -4215,7 +5189,7 @@ return {
|
|
|
4215
5189
|
*/
|
|
4216
5190
|
function askForFolder(preselect = null) {
|
|
4217
5191
|
return new Promise(resolve => {
|
|
4218
|
-
const modal = E.modal({title: 'New folder', size: 'sm'});
|
|
5192
|
+
const modal = trackModal(E.modal({title: 'New folder', size: 'sm'}));
|
|
4219
5193
|
|
|
4220
5194
|
const wrap = document.createElement('div');
|
|
4221
5195
|
wrap.className = 'mail-form-stack';
|
|
@@ -4321,7 +5295,7 @@ return {
|
|
|
4321
5295
|
visible ? `${folder.name} will show in the list.` : `${folder.name} is hidden.`,
|
|
4322
5296
|
{type: 'success'}
|
|
4323
5297
|
);
|
|
4324
|
-
await loadFolders();
|
|
5298
|
+
await loadFolders({quiet: true});
|
|
4325
5299
|
} catch (err) {
|
|
4326
5300
|
E.toast(err.message || 'That could not be changed.', {type: 'error'});
|
|
4327
5301
|
}
|
|
@@ -4334,8 +5308,11 @@ return {
|
|
|
4334
5308
|
* @returns {Promise<void>}
|
|
4335
5309
|
*/
|
|
4336
5310
|
async function renameFolder(folder) {
|
|
4337
|
-
|
|
4338
|
-
|
|
5311
|
+
// The current name goes in as the input's value, after the
|
|
5312
|
+
// dialog is built - `inputValue` would cut it at a quote.
|
|
5313
|
+
const name = await promptWith(esc(`Rename "${folder.name}" to`),
|
|
5314
|
+
{title: 'Rename folder', confirmText: 'Rename'}, folder.name);
|
|
5315
|
+
if (!here() || !name || String(name).trim() === folder.name) return;
|
|
4339
5316
|
|
|
4340
5317
|
try {
|
|
4341
5318
|
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
@@ -4346,7 +5323,7 @@ return {
|
|
|
4346
5323
|
// exists under that name.
|
|
4347
5324
|
if (currentFolder === folder.path) currentFolder = result.path;
|
|
4348
5325
|
E.toast(`Renamed to ${result.path}.`, {type: 'success'});
|
|
4349
|
-
await loadFolders();
|
|
5326
|
+
await loadFolders({quiet: true});
|
|
4350
5327
|
} catch (err) {
|
|
4351
5328
|
E.toast(err.message || 'That folder could not be renamed.', {type: 'error'});
|
|
4352
5329
|
}
|
|
@@ -4369,7 +5346,8 @@ return {
|
|
|
4369
5346
|
const question = children.length
|
|
4370
5347
|
? `Delete "${folder.path}" and the ${children.length} folder${children.length === 1 ? '' : 's'} inside it (${children.join(', ')})? Every message in them is deleted too.`
|
|
4371
5348
|
: `Delete "${folder.path}"? Every message in it is deleted too.`;
|
|
4372
|
-
|
|
5349
|
+
// Folder names are the server's text, and E.confirm renders HTML.
|
|
5350
|
+
if (!await ask(esc(question), {title: 'Delete folder', confirmText: 'Delete', cancelText: 'Keep it'}) || !here()) return;
|
|
4373
5351
|
|
|
4374
5352
|
try {
|
|
4375
5353
|
const params = new URLSearchParams({
|
|
@@ -4383,7 +5361,7 @@ return {
|
|
|
4383
5361
|
}
|
|
4384
5362
|
const extra = result.alsoDeleted?.length ? ` and ${result.alsoDeleted.length} inside it` : '';
|
|
4385
5363
|
E.toast(`Deleted ${result.path}${extra}.`, {type: 'success'});
|
|
4386
|
-
await loadFolders();
|
|
5364
|
+
await loadFolders({quiet: true});
|
|
4387
5365
|
} catch (err) {
|
|
4388
5366
|
E.toast(err.message || 'That folder could not be deleted.', {type: 'error'});
|
|
4389
5367
|
}
|
|
@@ -4444,7 +5422,7 @@ return {
|
|
|
4444
5422
|
// Saving or sending changes which UIDs are in Drafts, so
|
|
4445
5423
|
// the list behind the window has to be refetched either
|
|
4446
5424
|
// way - not only on send.
|
|
4447
|
-
onSaved: () => loadMessages()
|
|
5425
|
+
onSaved: () => loadMessages({quiet: true})
|
|
4448
5426
|
}));
|
|
4449
5427
|
} catch (err) {
|
|
4450
5428
|
E.toast(err.message || 'That draft could not be opened.', {type: 'error'});
|
|
@@ -4627,7 +5605,7 @@ return {
|
|
|
4627
5605
|
async function undoMove({uid, from, to}) {
|
|
4628
5606
|
const params = new URLSearchParams({account: currentAccountId ?? '', folder: from});
|
|
4629
5607
|
await api(`${BASE}/messages/${uid}/move?${params}`, 'POST', {destination: to});
|
|
4630
|
-
await loadMessages();
|
|
5608
|
+
await loadMessages({quiet: true});
|
|
4631
5609
|
}
|
|
4632
5610
|
|
|
4633
5611
|
/**
|
|
@@ -4737,11 +5715,28 @@ return {
|
|
|
4737
5715
|
if (!folder || !Number.isFinite(folder.unseen)) return;
|
|
4738
5716
|
folder.unseen = Math.max(0, folder.unseen + delta);
|
|
4739
5717
|
renderFolders();
|
|
4740
|
-
// The sidebar's unread badge counts the mirror; ask it to look
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
5718
|
+
// The sidebar's unread badge counts the mirror; ask it to look again.
|
|
5719
|
+
refreshBadge();
|
|
5720
|
+
}
|
|
5721
|
+
|
|
5722
|
+
/**
|
|
5723
|
+
* Ask the sidebar to re-read this Tool's badge. Named by the plugin
|
|
5724
|
+
* this view was built for (#/plugins/<slug>) - the reader is shared by
|
|
5725
|
+
* more than one mail plugin, and the address may carry more after it.
|
|
5726
|
+
*
|
|
5727
|
+
* @returns {void}
|
|
5728
|
+
*/
|
|
5729
|
+
function refreshBadge() {
|
|
5730
|
+
sidebarCount(sidebarUrl(pluginName));
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
/**
|
|
5734
|
+
* What the list shows, enough to tell whether a reload brought mail.
|
|
5735
|
+
*
|
|
5736
|
+
* @returns {{total: number, uids: number[]}}
|
|
5737
|
+
*/
|
|
5738
|
+
function listSnapshot() {
|
|
5739
|
+
return {total, uids: messages.map(m => m.uid)};
|
|
4745
5740
|
}
|
|
4746
5741
|
|
|
4747
5742
|
/**
|
|
@@ -4830,15 +5825,38 @@ return {
|
|
|
4830
5825
|
// Boot
|
|
4831
5826
|
// ---------------------------------------------------------------
|
|
4832
5827
|
|
|
5828
|
+
/** Bumped by every folder load, so a mailbox switched away from cannot draw. */
|
|
5829
|
+
let foldersSeq = 0;
|
|
5830
|
+
|
|
4833
5831
|
/**
|
|
4834
5832
|
* Load the selected mailbox's folders and open its inbox.
|
|
4835
5833
|
*
|
|
4836
5834
|
* @returns {Promise<void>}
|
|
4837
5835
|
*/
|
|
4838
|
-
async function loadFolders() {
|
|
5836
|
+
async function loadFolders({quiet = false} = {}) {
|
|
5837
|
+
const seq = ++foldersSeq;
|
|
4839
5838
|
try {
|
|
4840
5839
|
const params = new URLSearchParams({account: currentAccountId ?? '', counts: '1'});
|
|
4841
|
-
|
|
5840
|
+
let result;
|
|
5841
|
+
if (quiet) {
|
|
5842
|
+
// A reload after a folder was made, renamed, hidden or
|
|
5843
|
+
// deleted: the pane stays as it is until the answer.
|
|
5844
|
+
result = await api(`${BASE}/folders?${params}`);
|
|
5845
|
+
} else {
|
|
5846
|
+
// Another mailbox: nothing to show until this answers -
|
|
5847
|
+
// the mailbox rows stay, the old mailbox's folders go, and
|
|
5848
|
+
// a skeleton stands where the new ones will be. The
|
|
5849
|
+
// message list waits with it.
|
|
5850
|
+
folders = [];
|
|
5851
|
+
renderFolders();
|
|
5852
|
+
if (!searchQuery) {
|
|
5853
|
+
disposeRowEffects();
|
|
5854
|
+
listEl.textContent = '';
|
|
5855
|
+
pagerEl.textContent = '';
|
|
5856
|
+
}
|
|
5857
|
+
result = await withSkeleton(foldersEl, () => api(`${BASE}/folders?${params}`), 'rows');
|
|
5858
|
+
}
|
|
5859
|
+
if (!here() || seq !== foldersSeq) return;
|
|
4842
5860
|
folders = result.folders;
|
|
4843
5861
|
currentAccountId = result.accountId;
|
|
4844
5862
|
rememberAccount(currentAccountId);
|
|
@@ -4861,7 +5879,15 @@ return {
|
|
|
4861
5879
|
: 'Inbox';
|
|
4862
5880
|
renderFolders();
|
|
4863
5881
|
} catch (err) {
|
|
5882
|
+
if (!here() || seq !== foldersSeq) return;
|
|
5883
|
+
renderFolders();
|
|
4864
5884
|
handleError(err);
|
|
5885
|
+
if (!err.needsSetup && !foldersEl.querySelector('.mail-folder')) {
|
|
5886
|
+
const note = document.createElement('p');
|
|
5887
|
+
note.className = 'text-sm text-muted mail-none';
|
|
5888
|
+
note.textContent = 'The folders could not be loaded. Refresh to try again.';
|
|
5889
|
+
foldersEl.appendChild(note);
|
|
5890
|
+
}
|
|
4865
5891
|
return;
|
|
4866
5892
|
}
|
|
4867
5893
|
|
|
@@ -4894,8 +5920,17 @@ return {
|
|
|
4894
5920
|
refreshContacts();
|
|
4895
5921
|
loadMessageActions(api).then(actions => { messageActions = actions; });
|
|
4896
5922
|
|
|
4897
|
-
|
|
5923
|
+
// The first visit has nothing in the folder pane yet: a
|
|
5924
|
+
// skeleton while the mailboxes are asked for. A reload (after
|
|
5925
|
+
// a save) keeps what is there.
|
|
5926
|
+
const answer = mailboxes.length
|
|
5927
|
+
? await api(`${BASE}/accounts`)
|
|
5928
|
+
: await withSkeleton(foldersEl, () => api(`${BASE}/accounts`), 'rows');
|
|
5929
|
+
if (!here()) return;
|
|
5930
|
+
mailboxes = answer.accounts;
|
|
5931
|
+
pruneMailboxDrafts();
|
|
4898
5932
|
} catch (err) {
|
|
5933
|
+
if (!here()) return;
|
|
4899
5934
|
E.toast(err.message, {type: 'error'});
|
|
4900
5935
|
return;
|
|
4901
5936
|
}
|
|
@@ -4968,16 +6003,18 @@ return {
|
|
|
4968
6003
|
// this one can do (readOnlyMenuItems for the free reader).
|
|
4969
6004
|
attachMessageMenu();
|
|
4970
6005
|
attachBodyMenu();
|
|
4971
|
-
|
|
6006
|
+
// Every edition: the free reader's folders and mailbox rows have a
|
|
6007
|
+
// menu too (open, fold, copy; edit or remove a mailbox).
|
|
6008
|
+
attachFolderMenu();
|
|
4972
6009
|
|
|
4973
6010
|
// Shift+F10 opens the menu of the focused row, as the keyboard's
|
|
4974
6011
|
// right-click - the rows are buttons, so Enter already opens them.
|
|
4975
6012
|
const onMenuKey = (event) => {
|
|
4976
6013
|
if (!(event.key === 'F10' && event.shiftKey)) return;
|
|
4977
|
-
const row = event.target.closest?.('.mail-item, .mail-folder');
|
|
6014
|
+
const row = event.target.closest?.('.mail-item, .mail-folder, .mail-account');
|
|
4978
6015
|
if (!row) return;
|
|
4979
6016
|
event.preventDefault();
|
|
4980
|
-
openMenuAt(row);
|
|
6017
|
+
openMenuAt(row.querySelector('.mail-item-more, .mail-row-more') ?? row);
|
|
4981
6018
|
};
|
|
4982
6019
|
listEl?.addEventListener('keydown', onMenuKey);
|
|
4983
6020
|
foldersEl?.addEventListener('keydown', onMenuKey);
|
|
@@ -5028,6 +6065,7 @@ return {
|
|
|
5028
6065
|
newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
|
|
5029
6066
|
}
|
|
5030
6067
|
}
|
|
6068
|
+
addPaneActions();
|
|
5031
6069
|
|
|
5032
6070
|
// A real <input> here, unlike the row ticks: this one is not inside a
|
|
5033
6071
|
// button, so it can be what it is.
|
|
@@ -5131,7 +6169,11 @@ return {
|
|
|
5131
6169
|
: result.total !== total || signature(result.messages) !== signature(messages);
|
|
5132
6170
|
observedThreadPage = snapshot;
|
|
5133
6171
|
if (changed) {
|
|
5134
|
-
|
|
6172
|
+
const before = listSnapshot();
|
|
6173
|
+
await loadMessages({quiet: true});
|
|
6174
|
+
// New mail arrived while the screen sat open: the
|
|
6175
|
+
// sidebar's unread badge is the other place it shows.
|
|
6176
|
+
if (here() && hasNewMail(before, listSnapshot())) refreshBadge();
|
|
5135
6177
|
}
|
|
5136
6178
|
} catch { /* A temporary failure must not replace the open reader. */ }
|
|
5137
6179
|
finally { refreshing = false; }
|