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.
Files changed (111) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +453 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1207 -165
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. 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[]}} options
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. No draft: these forms hold passwords.
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} [question]
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?', onClosed = () => {}) {
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
- const ok = await E.confirm(question);
540
- asking = false;
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
- onClosed();
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
- // Each section saves on its own button, so the panel itself has
762
- // nothing to lose - guarded only to be closed with the screen.
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
- () => false, undefined, () => {
765
- stopSettingsResize();
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
- container.appendChild(mailboxSectionBody());
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} \u00b7 ${box.oauth.provider === 'google' ? 'Google' : 'Microsoft'} sign-in`
984
- : `${box.username} \u00b7 ${box.host}:${box.port}${box.secure ? '' : ' (no TLS)'}`;
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 removeBtn = document.createElement('button');
1002
- removeBtn.className = 'btn btn-sm btn-ghost';
1003
- removeBtn.classList.add('text-danger');
1004
- removeBtn.textContent = 'Remove';
1005
- removeBtn.addEventListener('click', async () => {
1006
- if (!await E.confirm(`Remove "${box.label}"? The stored password is deleted.`)) return;
1007
- try {
1008
- await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'DELETE');
1009
- E.toast('Mailbox removed.', {type: 'success'});
1010
- if (currentAccountId === box.id) currentAccountId = null;
1011
- await start();
1012
- row.remove();
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
- * Open the add/edit form for one mailbox.
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 openMailboxForm(box) {
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
- const labelInput = field(pwWrap, 'label', 'Name', 'text', box?.label ?? '', 'What to call this mailbox.');
1084
- const hostInput = field(pwWrap, 'host', 'IMAP server', 'text', box?.host ?? '', 'e.g. mail.example.com');
1085
- const portInput = field(pwWrap, 'port', 'Port', 'number', String(box?.port ?? 993));
1086
- const userInput = field(pwWrap, 'username', 'Username', 'text', box?.username ?? '');
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 ? box.secure !== false : true;
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 = false;
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 outgoing-server form.
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 it opened with; a typed password always counts as a change.
1157
- const opened = JSON.stringify({...collect(), password: ''});
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 !== '' || JSON.stringify({...collect(), password: ''}) !== opened
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
- () => { stopFormResize(); signInPanel?.dispose(); stopChoiceHelp?.(); });
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
- mailboxes = (await api(`${BASE}/accounts`)).accounts;
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\u2026');
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
- } finally {
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.addEventListener('click', () => selectAccount(box.id));
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 loader = E.loader(listEl, {type: 'dots'});
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
- handleError(err);
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 loader = E.loader(listEl, {type: 'dots'});
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
- const result = await api(`${BASE}/threads?${params}`);
1633
-
1634
- if (!result.mirrored) {
1635
- note('This folder is not mirrored, so it cannot be grouped into conversations.');
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
- const flat = await api(`${BASE}/messages?${params}`);
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
- handleError(err);
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 loader = E.loader(listEl, {type: 'dots'});
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
- handleError(err);
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 E.confirm(`Delete ${uids.length} message${uids.length === 1 ? '' : 's'}?`)) return;
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
- const modal = E.slideover({title: 'Keyboard shortcuts', size: 'sm', position: 'right'});
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
- const slideover = E.slideover({title: 'Waiting', size: 'md', position: 'right'});
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 [outbox, snoozed] = await Promise.all([
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
- const at = await askForTime('Snooze until');
3600
- if (!at) return;
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
- if (!await E.confirm(`Unsubscribe from the list "${message.subject || 'this message'}" came from?`)) return;
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 E.confirm(`Open "${result.title}" in the page editor now?`)) {
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
- return [
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
- const chosen = pickParent ? await askForFolder(parent) : {parent, name: null};
4180
- if (!chosen) return;
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 E.prompt(
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
- const name = await E.prompt(`Rename "${folder.name}" to`, {value: folder.name});
4338
- if (!name || String(name).trim() === folder.name) return;
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
- if (!await E.confirm(question)) return;
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
- // again. Its url is this screen's plugin (#/plugins/<slug>) - the
4742
- // reader is shared by more than one mail plugin.
4743
- const pluginUrl = location.hash.split('/').slice(0, 3).join('/');
4744
- window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: pluginUrl}}));
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
- const result = await api(`${BASE}/folders?${params}`);
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
- mailboxes = (await api(`${BASE}/accounts`)).accounts;
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
- if (features.folders) attachFolderMenu();
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
- await loadMessages();
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; }