@bobfrankston/rmfmail 1.0.680 → 1.0.686

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 (83) hide show
  1. package/bin/build-quill.js +35 -0
  2. package/bin/lean-accounts.js +0 -1
  3. package/client/app.bundle.js +172 -55
  4. package/client/app.bundle.js.map +2 -2
  5. package/client/app.js +73 -27
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +73 -29
  8. package/client/components/context-menu.js +2 -0
  9. package/client/components/context-menu.js.map +1 -1
  10. package/client/components/context-menu.ts +6 -0
  11. package/client/components/folder-tree.js +26 -4
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +21 -4
  14. package/client/components/message-list.js +108 -40
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +103 -38
  17. package/client/compose/compose.bundle.js +189 -17
  18. package/client/compose/compose.bundle.js.map +3 -3
  19. package/client/compose/compose.js +51 -3
  20. package/client/compose/compose.js.map +1 -1
  21. package/client/compose/compose.ts +47 -3
  22. package/client/compose/spellcheck.js +178 -12
  23. package/client/compose/spellcheck.js.map +1 -1
  24. package/client/compose/spellcheck.ts +168 -8
  25. package/client/lib/api-client.js +3 -0
  26. package/client/lib/api-client.js.map +1 -1
  27. package/client/lib/api-client.ts +4 -0
  28. package/client/lib/mailxapi.js +3 -0
  29. package/client/lib/quill/quill.js +3 -0
  30. package/client/lib/quill/quill.snow.css +10 -0
  31. package/client/lib/rmf-tiny.js +25 -6
  32. package/docs/accounts.md +7 -2
  33. package/package.json +8 -8
  34. package/packages/mailx-core/index.d.ts.map +1 -1
  35. package/packages/mailx-core/index.js +2 -12
  36. package/packages/mailx-core/index.js.map +1 -1
  37. package/packages/mailx-core/index.ts +2 -12
  38. package/packages/mailx-imap/index.d.ts.map +1 -1
  39. package/packages/mailx-imap/index.js +31 -6
  40. package/packages/mailx-imap/index.js.map +1 -1
  41. package/packages/mailx-imap/index.ts +32 -6
  42. package/packages/mailx-imap/node_modules.npmglobalize-stash-11884/.package-lock.json +116 -0
  43. package/packages/mailx-imap/package-lock.json +2 -2
  44. package/packages/mailx-imap/package.json +1 -1
  45. package/packages/mailx-service/index.d.ts +22 -0
  46. package/packages/mailx-service/index.d.ts.map +1 -1
  47. package/packages/mailx-service/index.js +134 -6
  48. package/packages/mailx-service/index.js.map +1 -1
  49. package/packages/mailx-service/index.ts +128 -11
  50. package/packages/mailx-service/jsonrpc.js +3 -0
  51. package/packages/mailx-service/jsonrpc.js.map +1 -1
  52. package/packages/mailx-service/jsonrpc.ts +3 -0
  53. package/packages/mailx-service/local-store.d.ts.map +1 -1
  54. package/packages/mailx-service/local-store.js +15 -12
  55. package/packages/mailx-service/local-store.js.map +1 -1
  56. package/packages/mailx-service/local-store.ts +15 -12
  57. package/packages/mailx-settings/docs/accounts.md +14 -1
  58. package/packages/mailx-settings/docs/npmglobalize-disttag.md +90 -0
  59. package/packages/mailx-settings/docs/prod-android.md +88 -0
  60. package/packages/mailx-settings/docs/prod.md +224 -0
  61. package/packages/mailx-settings/docs/push-relay.md +141 -0
  62. package/packages/mailx-settings/docs/rmf-tiny.md +156 -0
  63. package/packages/mailx-settings/index.d.ts +2 -2
  64. package/packages/mailx-settings/index.d.ts.map +1 -1
  65. package/packages/mailx-settings/index.js +13 -10
  66. package/packages/mailx-settings/index.js.map +1 -1
  67. package/packages/mailx-settings/index.ts +13 -9
  68. package/packages/mailx-settings/package.json +1 -1
  69. package/packages/mailx-store/db.d.ts.map +1 -1
  70. package/packages/mailx-store/db.js +44 -6
  71. package/packages/mailx-store/db.js.map +1 -1
  72. package/packages/mailx-store/db.ts +47 -6
  73. package/packages/mailx-store/package.json +1 -1
  74. package/packages/mailx-store-web/package.json +4 -1
  75. package/packages/mailx-store-web/web-settings.d.ts.map +1 -1
  76. package/packages/mailx-store-web/web-settings.js +0 -1
  77. package/packages/mailx-store-web/web-settings.js.map +1 -1
  78. package/packages/mailx-store-web/web-settings.ts +0 -1
  79. package/packages/mailx-types/index.d.ts +1 -2
  80. package/packages/mailx-types/index.d.ts.map +1 -1
  81. package/packages/mailx-types/index.js.map +1 -1
  82. package/packages/mailx-types/index.ts +1 -2
  83. package/packages/mailx-types/package.json +1 -1
@@ -70,6 +70,73 @@ function cacheKey(mode: "folder" | "search", a?: string, f?: number, flagged?: b
70
70
  return `search:${q}`;
71
71
  }
72
72
 
73
+ /** Per-view position memory — remembered selected UID + scroll position
74
+ * per folder / unified inbox / saved search. Keyed by the same string
75
+ * `cacheKey()` uses so each view has its own slot.
76
+ *
77
+ * Restore rule (Bob 2026-05-12): on return to a view, focus the saved
78
+ * uid if it still exists; otherwise focus the first row whose uid is
79
+ * less than the saved uid (next-older entry in a date-desc list — uid
80
+ * is roughly monotonic with arrival time on most IMAP servers and on
81
+ * Gmail's hash-derived ids). If no smaller uid exists either, fall
82
+ * back to whatever row sits at the same numeric index. Survives a
83
+ * session reload via sessionStorage. */
84
+ interface ViewPosition { uid: number; scroll: number; }
85
+ const positionMemory = new Map<string, ViewPosition>();
86
+ const POSITION_STORAGE_KEY = "mailx-list-positions";
87
+ try {
88
+ const raw = sessionStorage.getItem(POSITION_STORAGE_KEY);
89
+ if (raw) {
90
+ const parsed = JSON.parse(raw) as Record<string, ViewPosition>;
91
+ for (const [k, v] of Object.entries(parsed || {})) {
92
+ if (typeof v?.uid === "number") positionMemory.set(k, v);
93
+ }
94
+ }
95
+ } catch { /* */ }
96
+ function persistPositions(): void {
97
+ try {
98
+ const obj: Record<string, ViewPosition> = {};
99
+ for (const [k, v] of positionMemory) obj[k] = v;
100
+ sessionStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(obj));
101
+ } catch { /* */ }
102
+ }
103
+ function currentViewKey(): string | null {
104
+ if (searchMode) return cacheKey("search", undefined, undefined, undefined, currentSearchQuery);
105
+ if (unifiedMode) return CACHE_KEY_UNIFIED;
106
+ if (!currentAccountId || currentFolderId == null) return null;
107
+ const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
108
+ return cacheKey("folder", currentAccountId, currentFolderId, flaggedOnly);
109
+ }
110
+ function rememberPosition(): void {
111
+ const key = currentViewKey();
112
+ if (!key) return;
113
+ const body = document.getElementById("ml-body");
114
+ if (!body) return;
115
+ const sel = body.querySelector(".ml-row.selected") as HTMLElement | null;
116
+ if (!sel) return;
117
+ const uid = Number(sel.dataset.uid);
118
+ if (!Number.isFinite(uid)) return;
119
+ positionMemory.set(key, { uid, scroll: body.scrollTop });
120
+ persistPositions();
121
+ }
122
+ /** Choose the row to focus when re-entering a view with saved position.
123
+ * Returns the uid to focus, or null to fall back to selectFirst. */
124
+ function pickRestoreUid(items: any[], saved: number): number | null {
125
+ if (!items.length) return null;
126
+ if (items.some(m => m.uid === saved)) return saved;
127
+ // Next-older entry: largest remaining uid that is < saved. Stable
128
+ // even if list is unsorted by uid (loops through all rows).
129
+ let best = -1;
130
+ for (const m of items) {
131
+ if (typeof m.uid !== "number") continue;
132
+ if (m.uid < saved && m.uid > best) best = m.uid;
133
+ }
134
+ if (best >= 0) return best;
135
+ // No older entry — saved was the bottom of the list. Snap to the
136
+ // first row that sorts "after" via date-desc tie-break (top of list).
137
+ return typeof items[0]?.uid === "number" ? items[0].uid : null;
138
+ }
139
+
73
140
  /** Quick equality check — same UID set, same flag pattern, same total.
74
141
  * Skip re-render when this returns true to avoid DOM churn / scroll-
75
142
  * jump on a refresh that didn't change anything. */
@@ -148,6 +215,8 @@ function focusRow(row: MessageRow): void {
148
215
  viewerShow(row.accountId, row.msg.uid, row.msg.folderId, undefined, false, row.msg);
149
216
  onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);
150
217
  document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: row.msg }));
218
+ // Persist position so returning to this view re-focuses this row.
219
+ rememberPosition();
151
220
  }
152
221
 
153
222
  /** Read the currently-focused message envelope. Used by app-level
@@ -552,29 +621,26 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
552
621
  const fromHeader = document.querySelector(".ml-col-from");
553
622
  if (fromHeader) fromHeader.textContent = "From";
554
623
 
555
- const savedScroll = !autoSelect ? body.scrollTop : 0;
556
- // Read selection LIVE — never store at function start. If we cache
557
- // it then await an IPC, the user's mid-flight click is overwritten
558
- // when the post-IPC re-render restores the stale value (Bob 2026-05-09:
559
- // "I just selected the 10:03 message and it showed and then bounced
560
- // back to the 15:49"). Live read picks up whatever the user clicked
561
- // most recently, even during the await.
562
- const currentSelectedUid = (): string | null =>
563
- !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") || null : null;
624
+ // Saved per-view position wins over current DOM selection — same
625
+ // rationale as loadMessages.
626
+ const remembered = positionMemory.get(CACHE_KEY_UNIFIED);
627
+ const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);
564
628
 
565
629
  // Instant first paint from cache. The IPC fires in background below
566
630
  // and only triggers a re-render if the result diverges from the
567
- // cached snapshot. The cache lives for the session; no TTL — the
568
- // re-render covers staleness. C126 part 1 (the part that doesn't
569
- // need cross-restart persistence; cold-start pre-render is part 2).
631
+ // cached snapshot.
570
632
  const cached = listCache.get(CACHE_KEY_UNIFIED);
571
633
  if (cached) {
572
- const preCacheUid = currentSelectedUid();
573
634
  totalMessages = cached.total;
574
635
  state.setMessages(cached.items);
575
636
  renderMessages(body, "", cached.items);
576
- if (autoSelect) selectFirst(body);
577
- else { body.scrollTop = savedScroll; restoreSelection(body, preCacheUid); }
637
+ const targetUid = remembered ? pickRestoreUid(cached.items, remembered.uid) : null;
638
+ if (targetUid != null) {
639
+ body.scrollTop = savedScroll;
640
+ restoreSelection(body, String(targetUid));
641
+ } else if (autoSelect) {
642
+ selectFirst(body);
643
+ }
578
644
  } else if (autoSelect) {
579
645
  body.innerHTML = `<div class="ml-empty">Loading...</div>`;
580
646
  }
@@ -596,17 +662,15 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
596
662
  return;
597
663
  }
598
664
 
599
- // Capture selection immediately before renderMessages clears it,
600
- // not earlier — preserves any click made during the IPC await.
601
- const preRenderUid = currentSelectedUid();
602
665
  state.setMessages(result.items);
603
666
  renderMessages(body, "", result.items);
604
667
 
605
- if (autoSelect) {
606
- selectFirst(body);
607
- } else {
668
+ const targetUid = remembered ? pickRestoreUid(result.items, remembered.uid) : null;
669
+ if (targetUid != null) {
608
670
  body.scrollTop = savedScroll;
609
- restoreSelection(body, preRenderUid);
671
+ restoreSelection(body, String(targetUid));
672
+ } else if (autoSelect) {
673
+ selectFirst(body);
610
674
  }
611
675
  } catch (e: any) {
612
676
  if (e.name === "AbortError") return;
@@ -735,26 +799,27 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
735
799
  const fromHeader = document.querySelector(".ml-col-from");
736
800
  if (fromHeader) fromHeader.textContent = showToInsteadOfFrom ? "To" : "From";
737
801
 
738
- const savedScroll = !autoSelect ? body.scrollTop : 0;
739
- // Live read — see loadUnifiedInbox above for why this can't be
740
- // captured at function start.
741
- const currentSelectedUid = (): string | null =>
742
- !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") || null : null;
743
-
744
802
  const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
745
803
  const cKey = cacheKey("folder", accountId, folderId, flaggedOnly);
804
+ // Saved per-view position takes priority over the DOM's current
805
+ // selection — when switching from folder A to B and back, the DOM
806
+ // shows B's selected row but we want to land on whatever was last
807
+ // focused in A.
808
+ const remembered = positionMemory.get(cKey);
809
+ const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);
746
810
  const cached = listCache.get(cKey);
747
811
  if (cached) {
748
- const preCacheUid = currentSelectedUid();
749
812
  totalMessages = cached.total;
750
813
  state.setMessages(cached.items);
751
814
  renderMessages(body, accountId, cached.items);
752
- if (autoSelect) selectFirst(body);
753
- else {
815
+ const targetUid = remembered ? pickRestoreUid(cached.items, remembered.uid) : null;
816
+ if (targetUid != null) {
754
817
  requestAnimationFrame(() => {
755
818
  body.scrollTop = savedScroll;
756
- restoreSelection(body, preCacheUid);
819
+ restoreSelection(body, String(targetUid));
757
820
  });
821
+ } else if (autoSelect) {
822
+ selectFirst(body);
758
823
  }
759
824
  } else if (autoSelect) {
760
825
  body.innerHTML = `<div class="ml-empty">Loading...</div>`;
@@ -778,19 +843,19 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
778
843
  return;
779
844
  }
780
845
 
781
- // Capture selection right before renderMessages clears it.
782
- const preRenderUid = currentSelectedUid();
783
846
  state.setMessages(result.items);
784
847
  renderMessages(body, accountId, result.items);
785
848
 
786
- if (autoSelect) {
787
- selectFirst(body);
788
- } else {
849
+ // Prefer saved position; otherwise default by autoSelect.
850
+ const targetUid = remembered ? pickRestoreUid(result.items, remembered.uid) : null;
851
+ if (targetUid != null) {
789
852
  requestAnimationFrame(() => {
790
853
  if (myGen !== loadGen) return;
791
854
  body.scrollTop = savedScroll;
792
- restoreSelection(body, preRenderUid);
855
+ restoreSelection(body, String(targetUid));
793
856
  });
857
+ } else if (autoSelect) {
858
+ selectFirst(body);
794
859
  }
795
860
  } catch (e: any) {
796
861
  if (e.name === "AbortError") return;
@@ -85,6 +85,7 @@ __export(api_client_exports, {
85
85
  logClientEvent: () => logClientEvent,
86
86
  markAsSpamMessages: () => markAsSpamMessages,
87
87
  markFolderRead: () => markFolderRead,
88
+ moveFolderToTrash: () => moveFolderToTrash,
88
89
  moveMessage: () => moveMessage,
89
90
  moveMessages: () => moveMessages,
90
91
  onEvent: () => onEvent,
@@ -357,6 +358,9 @@ function renameFolder(accountId, folderId, newName) {
357
358
  function deleteFolder(accountId, folderId) {
358
359
  return ipc().deleteFolder?.(accountId, folderId);
359
360
  }
361
+ function moveFolderToTrash(accountId, folderId) {
362
+ return ipc().moveFolderToTrash?.(accountId, folderId);
363
+ }
360
364
  function emptyFolder(accountId, folderId) {
361
365
  return ipc().emptyFolder?.(accountId, folderId);
362
366
  }
@@ -617,7 +621,23 @@ async function createTinyMceEditor(container2, opts = {}) {
617
621
  return;
618
622
  args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
619
623
  },
620
- content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
624
+ // Body font + quoted-reply styling. Without explicit rules for
625
+ // <blockquote> and div.reply the editor iframe renders the
626
+ // quoted block with no visual distinction from the user's own
627
+ // text — pasted reply quotes vanish into a wall of unformatted
628
+ // paragraphs (Bob 2026-05-12: "the body losing all formatting").
629
+ // The styles below match Thunderbird/Outlook conventions: a
630
+ // muted left-bar + indent on blockquotes, slight color shift on
631
+ // the "On … wrote:" attribution, untouched typography for
632
+ // everything else so genuine HTML formatting (bold / italic /
633
+ // tables / inline color) still comes through verbatim.
634
+ content_style: [
635
+ "body { font-family: system-ui, sans-serif; font-size: 14px; }",
636
+ "blockquote { border-left: 3px solid #c0c8d0; margin: 0 0 0 4px; padding: 2px 0 2px 10px; color: #555; }",
637
+ "div.reply { margin-top: 0.5em; }",
638
+ "div.reply > p:first-child { color: #666; font-size: 0.95em; margin: 0 0 4px 0; }",
639
+ "pre, code { font-family: ui-monospace, Consolas, Menlo, monospace; }"
640
+ ].join(" "),
621
641
  init_instance_callback: (ed) => resolve(ed),
622
642
  setup: (ed) => {
623
643
  if (opts.initialHtml)
@@ -714,10 +734,14 @@ async function createTinyMceEditor(container2, opts = {}) {
714
734
  focus() {
715
735
  editor2.focus();
716
736
  },
717
- setCursor(_pos) {
737
+ setCursor(pos) {
718
738
  try {
719
739
  editor2.selection.select(editor2.getBody(), true);
720
- editor2.selection.collapse(false);
740
+ editor2.selection.collapse(
741
+ pos === 0
742
+ /* true = start */
743
+ );
744
+ editor2.focus();
721
745
  } catch {
722
746
  }
723
747
  },
@@ -1766,7 +1790,7 @@ function decorate(editor2, sp) {
1766
1790
  p = p.parentNode;
1767
1791
  }
1768
1792
  }
1769
- const bookmark = editor2.selection?.getBookmark?.(2);
1793
+ const savedAbs = caretAbsOffsetFromBody(body);
1770
1794
  try {
1771
1795
  editor2.undoManager?.ignore?.(() => {
1772
1796
  const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
@@ -1791,6 +1815,16 @@ function decorate(editor2, sp) {
1791
1815
  return NodeFilter.FILTER_ACCEPT;
1792
1816
  }
1793
1817
  });
1818
+ let caretNode = null;
1819
+ let caretOffset = 0;
1820
+ const liveSel = doc.getSelection();
1821
+ if (liveSel && liveSel.rangeCount > 0) {
1822
+ const f = liveSel.focusNode;
1823
+ if (f && f.nodeType === Node.TEXT_NODE) {
1824
+ caretNode = f;
1825
+ caretOffset = liveSel.focusOffset;
1826
+ }
1827
+ }
1794
1828
  const hits = [];
1795
1829
  let n = walker.nextNode();
1796
1830
  const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
@@ -1803,6 +1837,9 @@ function decorate(editor2, sp) {
1803
1837
  const word = m[0];
1804
1838
  if (word.length < MIN_WORD_LEN)
1805
1839
  continue;
1840
+ if (caretNode === tn && caretOffset >= m.index && caretOffset <= m.index + word.length) {
1841
+ continue;
1842
+ }
1806
1843
  if (sp.correct(word))
1807
1844
  continue;
1808
1845
  hits.push({ node: tn, start: m.index, end: m.index + word.length });
@@ -1823,11 +1860,72 @@ function decorate(editor2, sp) {
1823
1860
  }
1824
1861
  });
1825
1862
  } finally {
1826
- if (bookmark)
1827
- try {
1828
- editor2.selection?.moveToBookmark?.(bookmark);
1829
- } catch {
1830
- }
1863
+ if (savedAbs != null)
1864
+ restoreCaretFromAbsOffset(body, savedAbs);
1865
+ }
1866
+ }
1867
+ function caretAbsOffsetFromBody(body) {
1868
+ const doc = body.ownerDocument;
1869
+ const sel = doc.getSelection();
1870
+ if (!sel || sel.rangeCount === 0)
1871
+ return null;
1872
+ const focusNode = sel.focusNode;
1873
+ const focusOffset = sel.focusOffset;
1874
+ if (!focusNode)
1875
+ return null;
1876
+ if (focusNode.nodeType !== Node.TEXT_NODE) {
1877
+ let abs2 = 0;
1878
+ const walker2 = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
1879
+ let n2 = walker2.nextNode();
1880
+ while (n2) {
1881
+ if (focusNode.contains(n2))
1882
+ break;
1883
+ const cmp = focusNode.compareDocumentPosition(n2);
1884
+ if (cmp & Node.DOCUMENT_POSITION_PRECEDING)
1885
+ abs2 += n2.data.length;
1886
+ n2 = walker2.nextNode();
1887
+ }
1888
+ return abs2;
1889
+ }
1890
+ let abs = 0;
1891
+ const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
1892
+ let n = walker.nextNode();
1893
+ while (n) {
1894
+ if (n === focusNode)
1895
+ return abs + focusOffset;
1896
+ abs += n.data.length;
1897
+ n = walker.nextNode();
1898
+ }
1899
+ return null;
1900
+ }
1901
+ function restoreCaretFromAbsOffset(body, abs) {
1902
+ const doc = body.ownerDocument;
1903
+ const sel = doc.getSelection();
1904
+ if (!sel)
1905
+ return;
1906
+ const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
1907
+ let acc = 0;
1908
+ let n = walker.nextNode();
1909
+ while (n) {
1910
+ const len = n.data.length;
1911
+ if (acc + len >= abs) {
1912
+ const range = doc.createRange();
1913
+ range.setStart(n, Math.max(0, abs - acc));
1914
+ range.collapse(true);
1915
+ sel.removeAllRanges();
1916
+ sel.addRange(range);
1917
+ return;
1918
+ }
1919
+ acc += len;
1920
+ n = walker.nextNode();
1921
+ }
1922
+ const last = walker.previousNode();
1923
+ if (last) {
1924
+ const range = doc.createRange();
1925
+ range.setStart(last, last.data.length);
1926
+ range.collapse(true);
1927
+ sel.removeAllRanges();
1928
+ sel.addRange(range);
1831
1929
  }
1832
1930
  }
1833
1931
  function installDecorationStyle(editor2) {
@@ -1920,18 +2018,37 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
1920
2018
  menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
1921
2019
  if (r.bottom > window.innerHeight)
1922
2020
  menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
2021
+ const docs = [parentDoc];
2022
+ try {
2023
+ const composeWin = parentDoc.defaultView;
2024
+ if (composeWin?.frameElement && composeWin.parent?.document && composeWin.parent.document !== parentDoc) {
2025
+ docs.push(composeWin.parent.document);
2026
+ }
2027
+ } catch {
2028
+ }
2029
+ try {
2030
+ const editorIframe = parentDoc.querySelector("iframe.tox-edit-area__iframe") || parentDoc.querySelector("iframe");
2031
+ const editorDoc = editorIframe?.contentDocument;
2032
+ if (editorDoc && editorDoc !== parentDoc)
2033
+ docs.push(editorDoc);
2034
+ } catch {
2035
+ }
1923
2036
  const dismiss2 = (e) => {
1924
2037
  if (e.type === "keydown" && e.key !== "Escape")
1925
2038
  return;
1926
2039
  if (e.type === "mousedown" && menu.contains(e.target))
1927
2040
  return;
1928
2041
  menu.remove();
1929
- parentDoc.removeEventListener("mousedown", dismiss2, true);
1930
- parentDoc.removeEventListener("keydown", dismiss2, true);
2042
+ for (const d of docs) {
2043
+ d.removeEventListener("mousedown", dismiss2, true);
2044
+ d.removeEventListener("keydown", dismiss2, true);
2045
+ }
1931
2046
  };
1932
2047
  setTimeout(() => {
1933
- parentDoc.addEventListener("mousedown", dismiss2, true);
1934
- parentDoc.addEventListener("keydown", dismiss2, true);
2048
+ for (const d of docs) {
2049
+ d.addEventListener("mousedown", dismiss2, true);
2050
+ d.addEventListener("keydown", dismiss2, true);
2051
+ }
1935
2052
  }, 0);
1936
2053
  }
1937
2054
  function replaceMarker(editor2, marker, replacement) {
@@ -1993,7 +2110,21 @@ function wireSpellcheck(editor2) {
1993
2110
  return;
1994
2111
  e.preventDefault();
1995
2112
  e.stopPropagation();
1996
- const sugs = sp.suggest(word).slice(0, 7);
2113
+ const transposed = [];
2114
+ for (let i = 0; i < word.length - 1; i++) {
2115
+ const swapped = word.slice(0, i) + word[i + 1] + word[i] + word.slice(i + 2);
2116
+ if (swapped !== word && sp.correct(swapped) && !transposed.includes(swapped)) {
2117
+ transposed.push(swapped);
2118
+ }
2119
+ }
2120
+ const nspellSugs = sp.suggest(word);
2121
+ const sugs = [];
2122
+ for (const s of [...transposed, ...nspellSugs]) {
2123
+ if (!sugs.includes(s))
2124
+ sugs.push(s);
2125
+ if (sugs.length >= 7)
2126
+ break;
2127
+ }
1997
2128
  const iframeEl = editor2.iframeElement;
1998
2129
  const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
1999
2130
  const items = [];
@@ -2040,7 +2171,7 @@ var init_spellcheck = __esm({
2040
2171
  import_nspell = __toESM(require_lib(), 1);
2041
2172
  USER_DICT_KEY = "mailx-user-dict";
2042
2173
  MARKER_ATTR = "data-mailx-spellerror";
2043
- DECORATE_DEBOUNCE_MS = 500;
2174
+ DECORATE_DEBOUNCE_MS = 1200;
2044
2175
  MIN_WORD_LEN = 3;
2045
2176
  SKIP_TAGS = /* @__PURE__ */ new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
2046
2177
  spellPromise = null;
@@ -2922,6 +3053,8 @@ function showContextMenu(x, y, items) {
2922
3053
  const el = document.createElement("div");
2923
3054
  el.className = "ctx-item" + (item.disabled ? " ctx-disabled" : "");
2924
3055
  el.textContent = item.label;
3056
+ if (item.tooltip)
3057
+ el.title = item.tooltip;
2925
3058
  if (!item.disabled) {
2926
3059
  el.addEventListener("click", () => {
2927
3060
  closeContextMenu();
@@ -2962,6 +3095,15 @@ document.addEventListener("contextmenu", () => {
2962
3095
 
2963
3096
  // client/compose/compose.ts
2964
3097
  logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
3098
+ var _composeT0 = performance.now();
3099
+ function _ctick(label) {
3100
+ const ms = (performance.now() - _composeT0).toFixed(0).padStart(5);
3101
+ try {
3102
+ logClientEvent(`compose-tick ${ms}ms ${label}`);
3103
+ } catch {
3104
+ }
3105
+ }
3106
+ _ctick("module body executing");
2965
3107
  function closeCompose() {
2966
3108
  logClientEvent("compose-close");
2967
3109
  try {
@@ -3000,8 +3142,8 @@ async function loadEditorAssets(type) {
3000
3142
  loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`)
3001
3143
  ]);
3002
3144
  } else {
3003
- loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
3004
- await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
3145
+ loadCSS("../lib/quill/quill.snow.css");
3146
+ await loadScript("../lib/quill/quill.js");
3005
3147
  }
3006
3148
  }
3007
3149
  var editorType = "quill";
@@ -3083,7 +3225,9 @@ async function tryEditor(type) {
3083
3225
  }
3084
3226
  }
3085
3227
  var activeEditorType = editorType;
3228
+ _ctick(`editor load start (${editorType})`);
3086
3229
  editor = await tryEditor(editorType);
3230
+ _ctick(`editor load end (${editorType}, ok=${!!editor})`);
3087
3231
  if (!editor) {
3088
3232
  const fallbackType = editorType === "quill" ? "tiptap" : "quill";
3089
3233
  logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
@@ -3668,13 +3812,41 @@ function scheduleDraftSave() {
3668
3812
  });
3669
3813
  }, DRAFT_INPUT_DEBOUNCE_MS);
3670
3814
  }
3815
+ var _parentInitReady = !!sessionStorage.getItem("composeInit");
3816
+ var _parentInitListeners = [];
3817
+ window.addEventListener("message", (e) => {
3818
+ if (e.data?.type !== "compose-init-ready") return;
3819
+ _parentInitReady = true;
3820
+ for (const fn of _parentInitListeners.splice(0)) fn();
3821
+ });
3822
+ function waitForParentInit(maxMs) {
3823
+ if (_parentInitReady) return Promise.resolve();
3824
+ return new Promise((resolve) => {
3825
+ const timer = setTimeout(() => {
3826
+ _parentInitReady = true;
3827
+ resolve();
3828
+ }, maxMs);
3829
+ _parentInitListeners.push(() => {
3830
+ clearTimeout(timer);
3831
+ resolve();
3832
+ });
3833
+ });
3834
+ }
3671
3835
  (async () => {
3836
+ _ctick("init IIFE start");
3837
+ if (!sessionStorage.getItem("composeInit")) {
3838
+ _ctick("waiting for parent init");
3839
+ await waitForParentInit(1500);
3840
+ _ctick("parent init received");
3841
+ }
3672
3842
  const stored = sessionStorage.getItem("composeInit");
3673
3843
  if (stored) {
3674
3844
  sessionStorage.removeItem("composeInit");
3675
3845
  const init = JSON.parse(stored);
3846
+ _ctick(`init parsed (mode=${init.mode}, bodyHtml=${init.bodyHtml?.length || 0} bytes)`);
3676
3847
  if (init.accounts && init.accounts.length > 0) {
3677
3848
  applyInit(init);
3849
+ _ctick("applyInit done \u2014 compose visible");
3678
3850
  getAccounts().then((fresh) => {
3679
3851
  if (Array.isArray(fresh) && fresh.length > 0) {
3680
3852
  init.accounts = fresh;