@bobfrankston/rmfmail 1.1.170 → 1.1.177

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 (53) hide show
  1. package/bin/mailx.js +140 -4
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +127 -5
  4. package/client/android-bootstrap.bundle.js +4 -3
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +91 -6
  7. package/client/app.bundle.js.map +2 -2
  8. package/client/app.js +39 -0
  9. package/client/app.js.map +1 -1
  10. package/client/app.ts +33 -0
  11. package/client/components/message-list.js +114 -7
  12. package/client/components/message-list.js.map +1 -1
  13. package/client/components/message-list.ts +113 -6
  14. package/client/compose/compose.bundle.js +2068 -1775
  15. package/client/compose/compose.bundle.js.map +4 -4
  16. package/client/compose/editor.js +85 -0
  17. package/client/compose/editor.js.map +1 -1
  18. package/client/compose/editor.ts +79 -0
  19. package/client/compose/spellcheck-core.js +253 -0
  20. package/client/compose/spellcheck-core.js.map +1 -0
  21. package/client/compose/spellcheck-core.ts +242 -0
  22. package/package.json +5 -5
  23. package/packages/mailx-core/index.js +1 -1
  24. package/packages/mailx-core/index.js.map +1 -1
  25. package/packages/mailx-core/index.ts +1 -1
  26. package/packages/mailx-imap/index.d.ts.map +1 -1
  27. package/packages/mailx-imap/index.js +15 -1
  28. package/packages/mailx-imap/index.js.map +1 -1
  29. package/packages/mailx-imap/index.ts +9 -1
  30. package/packages/mailx-imap/package-lock.json +2 -2
  31. package/packages/mailx-imap/package.json +1 -1
  32. package/packages/mailx-store/db.d.ts +9 -1
  33. package/packages/mailx-store/db.d.ts.map +1 -1
  34. package/packages/mailx-store/db.js +15 -2
  35. package/packages/mailx-store/db.js.map +1 -1
  36. package/packages/mailx-store/db.ts +18 -4
  37. package/packages/mailx-store/package.json +1 -1
  38. package/packages/mailx-store/store.js +1 -1
  39. package/packages/mailx-store/store.js.map +1 -1
  40. package/packages/mailx-store/store.ts +1 -1
  41. package/packages/mailx-store-web/android-bootstrap.js +1 -1
  42. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  43. package/packages/mailx-store-web/android-bootstrap.ts +1 -1
  44. package/packages/mailx-store-web/db.d.ts +2 -1
  45. package/packages/mailx-store-web/db.d.ts.map +1 -1
  46. package/packages/mailx-store-web/db.js +3 -2
  47. package/packages/mailx-store-web/db.js.map +1 -1
  48. package/packages/mailx-store-web/db.ts +4 -3
  49. package/packages/mailx-store-web/package.json +1 -1
  50. package/packages/mailx-store-web/sync-manager.js +1 -1
  51. package/packages/mailx-store-web/sync-manager.js.map +1 -1
  52. package/packages/mailx-store-web/sync-manager.ts +1 -1
  53. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-46236 → node_modules.npmglobalize-stash-50992}/.package-lock.json +0 -0
@@ -109,6 +109,47 @@ function currentViewKey(): string | null {
109
109
  const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
110
110
  return cacheKey("folder", currentAccountId, currentFolderId, flaggedOnly);
111
111
  }
112
+ /** Run `doRender` while preserving the visual position of the topmost
113
+ * visible row. If the user is scrolled past the top and new rows arrive
114
+ * above their current view (the IDLE-driven "new mail" case), the rows
115
+ * they were reading STAY at the same screen Y after render — no yank.
116
+ *
117
+ * Pattern: snapshot the topmost row's UUID + its current offset from the
118
+ * scroll-container top, do the render, then set scrollTop so that same
119
+ * row lands at the same offset. If the anchor row is gone (deleted),
120
+ * fall back to the existing scrollTop-by-pixels behavior. */
121
+ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
122
+ const scrollTop = body.scrollTop;
123
+ // If the user is at the very top, no anchor needed — let new rows
124
+ // appear naturally above and the scroll position stays at 0.
125
+ if (scrollTop < 4) {
126
+ doRender();
127
+ return;
128
+ }
129
+ const rowsBefore = Array.from(body.querySelectorAll<HTMLElement>(".ml-row"));
130
+ let anchorUuid = "";
131
+ let anchorOffsetWithinViewport = 0;
132
+ for (const r of rowsBefore) {
133
+ const visualTop = r.offsetTop - scrollTop;
134
+ // The first row whose top is at or below the viewport top is the
135
+ // visible anchor. visualTop in [0, height) is "first fully or
136
+ // partially visible row from the top."
137
+ if (visualTop >= 0) {
138
+ anchorUuid = r.dataset.uuid || "";
139
+ anchorOffsetWithinViewport = visualTop;
140
+ break;
141
+ }
142
+ }
143
+ doRender();
144
+ if (!anchorUuid) return; // nothing to anchor to (empty list before render)
145
+ const after = body.querySelector<HTMLElement>(`.ml-row[data-uuid="${CSS.escape(anchorUuid)}"]`);
146
+ if (after) {
147
+ body.scrollTop = after.offsetTop - anchorOffsetWithinViewport;
148
+ }
149
+ // If the anchor row was deleted, leave scrollTop as the browser placed
150
+ // it after the innerHTML swap. Don't actively yank.
151
+ }
152
+
112
153
  function rememberPosition(): void {
113
154
  const key = currentViewKey();
114
155
  if (!key) return;
@@ -294,9 +335,43 @@ export function setRowSeen(accountId: string, uid: number, yes: boolean): void {
294
335
  }
295
336
 
296
337
  /** Scroll the focused row into view. Wired to a keyboard shortcut so the
297
- * user can recover after scrolling the list away from the preview. */
338
+ * user can recover after scrolling the list away from the preview.
339
+ *
340
+ * Robust against partial re-renders: if focusedRow.el has been detached
341
+ * from the document (sync churned the row out, the list paginated, a
342
+ * filter changed), re-look-it-up by UID/account before giving up. Without
343
+ * this the original implementation would silently no-op against a stale
344
+ * DOM node — exactly the "Z does nothing" symptom from Bob 2026-05-27. */
298
345
  export function scrollFocusedIntoView(): void {
299
- if (focusedRow) focusedRow.el.scrollIntoView({ block: "center" });
346
+ if (!focusedRow) return;
347
+ let el: HTMLElement | null = focusedRow.el;
348
+ if (!document.body.contains(el)) {
349
+ const body = document.getElementById("ml-body");
350
+ const uid = (focusedRow.msg as any)?.uid;
351
+ if (body && uid != null) {
352
+ el = body.querySelector<HTMLElement>(
353
+ `.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(focusedRow.accountId)}"]`
354
+ ) || body.querySelector<HTMLElement>(`.ml-row[data-uid="${uid}"]`);
355
+ }
356
+ if (!el) {
357
+ // The row isn't in the loaded slice of the list. Tell the user
358
+ // visibly rather than silently no-op — they pressed a key, they
359
+ // deserve feedback.
360
+ const status = document.getElementById("status-sync");
361
+ if (status) {
362
+ status.textContent = "Selected message isn't in the loaded list — scroll down to load more, or open the folder it lives in.";
363
+ setTimeout(() => { if (status?.textContent?.startsWith("Selected message")) status.textContent = ""; }, 4000);
364
+ }
365
+ return;
366
+ }
367
+ // Refresh the cached element pointer so subsequent calls find it.
368
+ // Re-apply the highlight in case it got stripped during re-render —
369
+ // this also addresses the "selected message isn't highlighted"
370
+ // observation in the same session.
371
+ focusedRow.el = el;
372
+ el.classList.add("selected");
373
+ }
374
+ el.scrollIntoView({ block: "center" });
300
375
  }
301
376
 
302
377
  /** Release the focus slot and clear the preview pane in one call. */
@@ -783,11 +858,18 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
783
858
  }
784
859
 
785
860
  state.setMessages(result.items);
786
- renderMessages(body, "", result.items);
861
+ // Anchor on the topmost visible row before re-rendering so new
862
+ // mail arriving above the user's current scroll position doesn't
863
+ // yank them. If they're at the very top, no anchor — new rows
864
+ // appear above as usual.
865
+ withScrollAnchor(body, () => renderMessages(body, "", result.items));
787
866
 
788
867
  const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
789
868
  if (targetUuid) {
790
- body.scrollTop = savedScroll;
869
+ // Only restore the saved scrollTop on a first paint (cache miss).
870
+ // After a sync-driven re-render, withScrollAnchor has already
871
+ // placed the body — overriding here would undo the anchor.
872
+ if (!cached) body.scrollTop = savedScroll;
791
873
  restoreSelection(body, targetUuid);
792
874
  } else if (autoSelect) {
793
875
  selectFirst(body);
@@ -981,7 +1063,9 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
981
1063
  }
982
1064
 
983
1065
  state.setMessages(result.items);
984
- renderMessages(body, accountId, result.items);
1066
+ // Anchor on topmost visible row before render so a sync-driven
1067
+ // refresh doesn't yank the user's scroll position.
1068
+ withScrollAnchor(body, () => renderMessages(body, accountId, result.items));
985
1069
  // Same fate-sharing rule for the non-empty branch: if the focused
986
1070
  // viewer message isn't in the freshly-loaded set, clear it.
987
1071
  if (focusedRow) {
@@ -998,7 +1082,11 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
998
1082
  if (targetUuid) {
999
1083
  requestAnimationFrame(() => {
1000
1084
  if (myGen !== loadGen) return;
1001
- body.scrollTop = savedScroll;
1085
+ // On a first paint (no cache), use the saved scrollTop.
1086
+ // On a sync-driven re-render (cache existed), the scroll
1087
+ // anchor has already placed the body — overriding here
1088
+ // would undo the anchor and re-yank the user.
1089
+ if (!cached) body.scrollTop = savedScroll;
1002
1090
  restoreSelection(body, targetUuid);
1003
1091
  });
1004
1092
  } else if (autoSelect) {
@@ -1043,6 +1131,25 @@ function renderMessages(body: HTMLElement, accountId: string, items: any[]): voi
1043
1131
  appendMessages(tempDiv, accountId, items);
1044
1132
  while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
1045
1133
  body.replaceChildren(fragment);
1134
+ // FATE-SHARING (central rule): viewer state is a byproduct of list
1135
+ // selection. If the previously-focused row isn't in the freshly-
1136
+ // rendered set, the viewer MUST clear — otherwise the user sees a
1137
+ // preview of a message that no longer appears in the list, which is
1138
+ // the "preview but empty summary" bug. Doing this in renderMessages
1139
+ // (not at the per-caller site) covers loadMessages, loadUnifiedInbox,
1140
+ // loadSearchResults, reloadCurrentFolder, and every other code path
1141
+ // that funnels through here. Previous per-caller fate-sharing existed
1142
+ // only in loadMessages — the gap was real. */
1143
+ if (focusedRow) {
1144
+ const fAcct = focusedRow.accountId;
1145
+ const fUid = (focusedRow.msg as any)?.uid;
1146
+ const stillThere = items.some((m: any) =>
1147
+ (m.accountId || accountId) === fAcct && m.uid === fUid);
1148
+ if (!stillThere) {
1149
+ viewerClear();
1150
+ focusedRow = null;
1151
+ }
1152
+ }
1046
1153
  }
1047
1154
 
1048
1155
  function selectFirst(body: HTMLElement): void {