domma-cms 1.6.55 → 1.6.57

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