domma-cms 0.75.0 → 0.76.0

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