@bobfrankston/rmfmail 1.2.87 → 1.2.96

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 (82) hide show
  1. package/client/android-bootstrap.bundle.js +65 -15
  2. package/client/android-bootstrap.bundle.js.map +2 -2
  3. package/client/app.bundle.js +378 -286
  4. package/client/app.bundle.js.map +4 -4
  5. package/client/app.js +23 -1
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +20 -1
  8. package/client/components/address-book.js +5 -1
  9. package/client/components/address-book.js.map +1 -1
  10. package/client/components/address-book.ts +5 -1
  11. package/client/components/folder-tree.js +31 -6
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +29 -5
  14. package/client/components/message-list.js +164 -54
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +159 -51
  17. package/client/components/message-viewer.js +7 -0
  18. package/client/components/message-viewer.js.map +1 -1
  19. package/client/components/message-viewer.ts +7 -0
  20. package/client/compose/compose.bundle.js +4 -4
  21. package/client/compose/compose.bundle.js.map +2 -2
  22. package/client/index.html +4 -0
  23. package/client/lib/api-client.js +4 -4
  24. package/client/lib/api-client.js.map +1 -1
  25. package/client/lib/api-client.ts +4 -4
  26. package/client/lib/mailxapi.js +4 -4
  27. package/client/styles/components.css +20 -0
  28. package/package.json +9 -9
  29. package/packages/mailx-imap/index.d.ts +17 -0
  30. package/packages/mailx-imap/index.d.ts.map +1 -1
  31. package/packages/mailx-imap/index.js +86 -4
  32. package/packages/mailx-imap/index.js.map +1 -1
  33. package/packages/mailx-imap/index.ts +85 -4
  34. package/packages/mailx-imap/package-lock.json +2 -2
  35. package/packages/mailx-imap/package.json +1 -1
  36. package/packages/mailx-service/index.d.ts +2 -2
  37. package/packages/mailx-service/index.d.ts.map +1 -1
  38. package/packages/mailx-service/index.js +6 -4
  39. package/packages/mailx-service/index.js.map +1 -1
  40. package/packages/mailx-service/index.ts +7 -5
  41. package/packages/mailx-service/jsonrpc.js +2 -2
  42. package/packages/mailx-service/jsonrpc.js.map +1 -1
  43. package/packages/mailx-service/jsonrpc.ts +2 -2
  44. package/packages/mailx-service/sync-queue.d.ts +3 -0
  45. package/packages/mailx-service/sync-queue.d.ts.map +1 -1
  46. package/packages/mailx-service/sync-queue.js +30 -11
  47. package/packages/mailx-service/sync-queue.js.map +1 -1
  48. package/packages/mailx-service/sync-queue.ts +34 -9
  49. package/packages/mailx-service/sync-worker.js +51 -0
  50. package/packages/mailx-service/sync-worker.js.map +1 -1
  51. package/packages/mailx-service/sync-worker.ts +45 -0
  52. package/packages/mailx-settings/package.json +1 -1
  53. package/packages/mailx-store/db.d.ts +10 -1
  54. package/packages/mailx-store/db.d.ts.map +1 -1
  55. package/packages/mailx-store/db.js +68 -7
  56. package/packages/mailx-store/db.js.map +1 -1
  57. package/packages/mailx-store/db.ts +70 -8
  58. package/packages/mailx-store/package.json +1 -1
  59. package/packages/mailx-store/store.d.ts +1 -1
  60. package/packages/mailx-store/store.d.ts.map +1 -1
  61. package/packages/mailx-store/store.js +2 -2
  62. package/packages/mailx-store/store.js.map +1 -1
  63. package/packages/mailx-store/store.ts +2 -2
  64. package/packages/mailx-store-web/imap-web-provider.d.ts +7 -0
  65. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -1
  66. package/packages/mailx-store-web/imap-web-provider.js +12 -0
  67. package/packages/mailx-store-web/imap-web-provider.js.map +1 -1
  68. package/packages/mailx-store-web/imap-web-provider.ts +15 -0
  69. package/packages/mailx-store-web/package.json +1 -1
  70. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -1
  71. package/packages/mailx-store-web/sync-manager.js +12 -0
  72. package/packages/mailx-store-web/sync-manager.js.map +1 -1
  73. package/packages/mailx-store-web/sync-manager.ts +12 -0
  74. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  75. package/packages/mailx-store-web/web-service.js +53 -19
  76. package/packages/mailx-store-web/web-service.js.map +1 -1
  77. package/packages/mailx-store-web/web-service.ts +60 -20
  78. package/packages/mailx-types/index.d.ts +5 -1
  79. package/packages/mailx-types/index.d.ts.map +1 -1
  80. package/packages/mailx-types/index.js.map +1 -1
  81. package/packages/mailx-types/index.ts +5 -1
  82. package/packages/mailx-types/package.json +1 -1
@@ -87,6 +87,33 @@ if (typeof document !== "undefined") {
87
87
  // (text columns default asc, date defaults desc).
88
88
  let currentSort = "date";
89
89
  let currentSortDir = "desc";
90
+ // Date basis for the "date" sort + the date column: "sent" (Date: header,
91
+ // default — identical mail clusters, matches other clients) or "received"
92
+ // (per-account arrival time). Persisted so the View-menu toggle survives
93
+ // reloads. Bob 2026-07-01.
94
+ let dateBasis = localStorage.getItem("mailx-date-basis") || "sent";
95
+ /** The epoch-ms to DISPLAY for a row, honoring the current basis. sentDate is
96
+ * always populated by the store (backfilled to date), but guard anyway. */
97
+ function effectiveDate(msg) {
98
+ return dateBasis === "received" ? msg.date : (msg.sentDate ?? msg.date);
99
+ }
100
+ /** Current date basis — read by app.ts to check the View-menu radio state. */
101
+ export function getDateBasis() { return dateBasis; }
102
+ /** Switch the date basis, persist it, and reload the current view so the new
103
+ * sort + column take effect. Called by the View menu. */
104
+ export function setDateBasis(basis) {
105
+ if (basis === dateBasis)
106
+ return;
107
+ dateBasis = basis;
108
+ try {
109
+ localStorage.setItem("mailx-date-basis", basis);
110
+ }
111
+ catch { /* private mode */ }
112
+ // Cached snapshots were ordered by the old basis — clear so the reload
113
+ // re-sorts from the DB rather than repainting the stale order.
114
+ listCache.clear();
115
+ reloadCurrentFolder();
116
+ }
90
117
  /** Generation counter — incremented on every load* call (loadMessages,
91
118
  * loadUnifiedInbox, loadSearchResults). Each load captures the current
92
119
  * value at the top, then checks before rendering — if the captured gen
@@ -165,6 +192,7 @@ function withScrollAnchor(body, doRender) {
165
192
  // must not go off screen"). The old `scrollTop < 4 → no anchor` shortcut
166
193
  // was exactly the bug: at the top, new rows shoved the read letter off the
167
194
  // bottom.
195
+ let anchoredOnSelected = false;
168
196
  const selected = body.querySelector(".ml-row.selected");
169
197
  if (selected) {
170
198
  const vt = selected.offsetTop - scrollTop;
@@ -172,6 +200,7 @@ function withScrollAnchor(body, doRender) {
172
200
  if (vt > -selected.offsetHeight && vt < viewportH) {
173
201
  anchorUuid = selected.dataset.uuid || "";
174
202
  anchorOffsetWithinViewport = vt;
203
+ anchoredOnSelected = true;
175
204
  }
176
205
  }
177
206
  if (!anchorUuid) {
@@ -203,7 +232,14 @@ function withScrollAnchor(body, doRender) {
203
232
  // bottom do we fall back to pinning it visible. This satisfies both
204
233
  // "show new mail at the top" and the 2026-06-04 rule "a letter in view
205
234
  // must stay in view (it may scroll down but must not go off screen)".
206
- if (wasAtTop && after.offsetTop < viewportH - after.offsetHeight) {
235
+ //
236
+ // EXCEPTION (Bob 2026-06-30: "I was looking at a message and suddenly
237
+ // my position changed and I was looking at a new message"): when the
238
+ // anchor IS the open/selected message, the user is actively reading —
239
+ // never reposition them. Pin the selected row exactly where it sat.
240
+ // The scroll-to-0 reveal only applies when nothing is open and the user
241
+ // is merely parked at the top of the list browsing subjects.
242
+ if (wasAtTop && !anchoredOnSelected && after.offsetTop < viewportH - after.offsetHeight) {
207
243
  body.scrollTop = 0;
208
244
  }
209
245
  else {
@@ -235,8 +271,20 @@ function rememberPosition() {
235
271
  persistPositions();
236
272
  }
237
273
  /** Choose the row to focus when re-entering a view with saved position.
238
- * Returns the uid to focus, or null to fall back to selectFirst. */
239
- function pickRestoreUid(items, saved) {
274
+ * Returns the uid to focus, or null to fall back to selectFirst.
275
+ *
276
+ * `allowNeighbor` gates the deleted-message fallback. On a FOREGROUND
277
+ * re-entry (user navigating INTO a view) the saved message being absent
278
+ * means it was deleted, so we hand focus to the nearest survivor. On a
279
+ * BACKGROUND reload (sync/new-mail rebuild of the current view) absent
280
+ * does NOT mean deleted — the message the user is READING has merely been
281
+ * pushed out of the loaded top-50 page by new arrivals. Switching to a
282
+ * neighbor there yanks the viewer onto the new arrival while the list
283
+ * stays put (Bob 2026-07-01: "I was looking at a letter and suddenly found
284
+ * myself looking at a newly arrived message; the summary did not scroll").
285
+ * So background callers pass allowNeighbor=false → return null → keep the
286
+ * current viewer untouched. */
287
+ function pickRestoreUid(items, saved, allowNeighbor = true) {
240
288
  if (!items.length)
241
289
  return null;
242
290
  // Exact restore by stable uuid — globally unique, so this can never
@@ -247,6 +295,8 @@ function pickRestoreUid(items, saved) {
247
295
  if (exact?.uuid)
248
296
  return exact.uuid;
249
297
  }
298
+ if (!allowNeighbor)
299
+ return null;
250
300
  // Saved message is gone (deleted). Pick the next-older entry by uid —
251
301
  // uid is roughly monotonic with arrival on IMAP/Gmail — and return ITS
252
302
  // uuid so the restore stays uuid-keyed end to end.
@@ -718,10 +768,32 @@ export function exitMultiSelect() {
718
768
  updateBulkBar();
719
769
  }
720
770
  /** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
721
- * toolbar now and every other bulk op (mark-read, flag, move) is one
722
- * right-click away. Kept as a no-op stub so existing call sites
723
- * (avatar tap, row click, long-press) don't need to be touched. */
724
- function updateBulkBar() { }
771
+ * toolbar. This now renders just the selection COUNT (Bob 2026-07-01:
772
+ * "multiselect should show the count somewhere") as a floating pill at the
773
+ * bottom of the list panel, shown only while a multi-selection of ≥2 is
774
+ * active. Every selection-changing site already calls this. */
775
+ function updateBulkBar() {
776
+ const panel = document.getElementById("message-list");
777
+ if (!panel)
778
+ return;
779
+ const body = document.getElementById("ml-body");
780
+ const count = body?.classList.contains("multi-select-on")
781
+ ? body.querySelectorAll(".ml-row.selected").length : 0;
782
+ let pill = document.getElementById("ml-select-count");
783
+ if (count >= 2) {
784
+ if (!pill) {
785
+ pill = document.createElement("div");
786
+ pill.id = "ml-select-count";
787
+ pill.className = "ml-select-count";
788
+ panel.appendChild(pill);
789
+ }
790
+ pill.textContent = `${count} selected`;
791
+ pill.style.display = "";
792
+ }
793
+ else if (pill) {
794
+ pill.style.display = "none";
795
+ }
796
+ }
725
797
  /** Select every shown (not filter-hidden) row AND enter multi-select mode.
726
798
  * Shared by the selection menu's "Select all shown" and the Ctrl+A shortcut.
727
799
  * CRITICAL: it sets `multi-select-on` — without that flag a subsequent ★ click
@@ -846,37 +918,7 @@ export function initMessageList(handler) {
846
918
  touchWasScroll = true;
847
919
  }
848
920
  }, { passive: true });
849
- body.addEventListener("scroll", () => {
850
- if (loading)
851
- return;
852
- // Prefetch the next page well BEFORE the bottom (was 200px). The next
853
- // 50 rows are a DB query + render; triggering it only 200px out meant
854
- // the user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
855
- // 2026-06-18 "scroll the summary, there is a delay"). 1000px ≈ a couple
856
- // screens of lead time so the page is usually ready by the time it's
857
- // scrolled to. `loading` guard still prevents overlapping fetches.
858
- const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
859
- if (nearBottom) {
860
- if (currentPage * 50 < totalMessages) {
861
- loadMoreMessages();
862
- }
863
- else {
864
- // Diagnostic: at bottom but not loading more. Log why
865
- // so the user can see whether totalMessages is wrong,
866
- // a filter is on, or we've truly reached the end.
867
- // Only log once per "stuck at bottom" episode.
868
- if (!body._mlScrollEndLogged) {
869
- body._mlScrollEndLogged = true;
870
- const rows = body.querySelectorAll(".ml-row").length;
871
- console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
872
- }
873
- }
874
- }
875
- else {
876
- // Reset the once-flag so next time we hit bottom we log again.
877
- body._mlScrollEndLogged = false;
878
- }
879
- });
921
+ body.addEventListener("scroll", () => { maybeLoadMore(body); });
880
922
  }
881
923
  // Viewer signals "this row is gone server-side" via mailx-remove-stale.
882
924
  // The list owns row lifecycle, so it runs the removal here — filtering
@@ -1110,7 +1152,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
1110
1152
  withScrollAnchor(body, renderCached);
1111
1153
  else
1112
1154
  renderCached();
1113
- const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1155
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
1114
1156
  if (targetUuid) {
1115
1157
  if (autoSelect)
1116
1158
  body.scrollTop = savedScroll;
@@ -1124,7 +1166,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
1124
1166
  body.innerHTML = `<div class="ml-empty">Loading...</div>`;
1125
1167
  }
1126
1168
  try {
1127
- const result = await apiGetUnifiedInbox(1, 50, flaggedOnly);
1169
+ const result = await apiGetUnifiedInbox(1, 50, flaggedOnly, dateBasis);
1128
1170
  if (myGen !== loadGen)
1129
1171
  return; // user moved on; drop stale response
1130
1172
  totalMessages = result.total;
@@ -1147,7 +1189,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
1147
1189
  // yank them. If they're at the very top, no anchor — new rows
1148
1190
  // appear above as usual.
1149
1191
  withScrollAnchor(body, () => renderMessages(body, "", result.items, { background: !autoSelect }));
1150
- const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1192
+ const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
1151
1193
  if (targetUuid) {
1152
1194
  // Only restore the saved scrollTop on a first paint (cache miss).
1153
1195
  // After a sync-driven re-render, withScrollAnchor has already
@@ -1228,8 +1270,8 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
1228
1270
  return;
1229
1271
  }
1230
1272
  const source = scope === "current" && accountId
1231
- ? await apiGetMessages(accountId, folderId, 1, 10000)
1232
- : await apiGetUnifiedInbox(1, 10000);
1273
+ ? await apiGetMessages(accountId, folderId, 1, 10000, false, currentSort, currentSortDir, dateBasis)
1274
+ : await apiGetUnifiedInbox(1, 10000, false, dateBasis);
1233
1275
  if (myGen !== loadGen)
1234
1276
  return;
1235
1277
  const matches = source.items.filter((m) => regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || ""));
@@ -1338,7 +1380,7 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1338
1380
  withScrollAnchor(body, renderCached);
1339
1381
  else
1340
1382
  renderCached();
1341
- const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1383
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
1342
1384
  if (focusUid != null) {
1343
1385
  // Search-as-find: jump to the specific message the user selected.
1344
1386
  requestAnimationFrame(() => {
@@ -1363,7 +1405,7 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1363
1405
  body.innerHTML = `<div class="ml-empty">Loading...</div>`;
1364
1406
  }
1365
1407
  try {
1366
- const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
1408
+ const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
1367
1409
  // Stale-response guard: a newer load* fired while we were
1368
1410
  // awaiting; the new view already painted. Drop this result
1369
1411
  // silently rather than overwriting.
@@ -1402,7 +1444,7 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1402
1444
  }
1403
1445
  }
1404
1446
  // Prefer saved position; otherwise default by autoSelect.
1405
- const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1447
+ const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
1406
1448
  if (focusUid != null) {
1407
1449
  // Search-as-find: select the message the user picked from results.
1408
1450
  requestAnimationFrame(() => {
@@ -1437,6 +1479,44 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1437
1479
  body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
1438
1480
  }
1439
1481
  }
1482
+ /** Decide whether the next page should load, and load it. Centralized so the
1483
+ * scroll handler and the post-load re-check share one code path.
1484
+ *
1485
+ * Why a re-check exists (Bob 2026-06-30: "scroll down near the bottom does
1486
+ * nothing till an upward scroll then I can scroll down again"): the browser
1487
+ * fires `scroll` events only while scrollTop actually changes. When the user
1488
+ * reaches the absolute bottom of the loaded rows, scrollTop is pinned at max,
1489
+ * so NO further downward scroll events fire. If the event that crossed
1490
+ * nearBottom arrived while a previous load was still in flight (the old
1491
+ * `if (loading) return` dropped it), the next page was never requested — the
1492
+ * list looked frozen until an upward scroll re-armed the handler. Now every
1493
+ * finished load re-evaluates geometry and chains the next page if the user is
1494
+ * still sitting near the bottom, so reaching the end is self-driving. */
1495
+ function maybeLoadMore(body) {
1496
+ if (loading)
1497
+ return;
1498
+ const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
1499
+ if (!nearBottom) {
1500
+ // Reset the once-flag so next time we hit bottom we log again.
1501
+ body._mlScrollEndLogged = false;
1502
+ return;
1503
+ }
1504
+ if (currentPage * 50 < totalMessages) {
1505
+ // Prefetch the next page well BEFORE the bottom (1000px lead). The next
1506
+ // 50 rows are a DB query + render; triggering only 200px out meant the
1507
+ // user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
1508
+ // 2026-06-18 "scroll the summary, there is a delay").
1509
+ loadMoreMessages();
1510
+ }
1511
+ else if (!body._mlScrollEndLogged) {
1512
+ // Diagnostic: at bottom but not loading more. Log why so we can see
1513
+ // whether totalMessages is wrong, a filter is on, or we've truly
1514
+ // reached the end. Once per "stuck at bottom" episode.
1515
+ body._mlScrollEndLogged = true;
1516
+ const rows = body.querySelectorAll(".ml-row").length;
1517
+ console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
1518
+ }
1519
+ }
1440
1520
  async function loadMoreMessages() {
1441
1521
  const body = document.getElementById("ml-body");
1442
1522
  if (!body)
@@ -1457,8 +1537,8 @@ async function loadMoreMessages() {
1457
1537
  const result = searchMode
1458
1538
  ? await searchMessages(currentSearchQuery, currentPage)
1459
1539
  : unifiedMode
1460
- ? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly)
1461
- : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
1540
+ ? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly, dateBasis)
1541
+ : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
1462
1542
  // Append to state
1463
1543
  const current = state.getMessages();
1464
1544
  state.setMessages([...current, ...result.items]);
@@ -1475,6 +1555,13 @@ async function loadMoreMessages() {
1475
1555
  finally {
1476
1556
  loading = false;
1477
1557
  }
1558
+ // Re-evaluate: if the user is STILL near the bottom (they kept their finger
1559
+ // pinned at the end, or the page that loaded was shorter than the viewport),
1560
+ // chain the next page. Geometry has settled after the synchronous append
1561
+ // above, so this reads true heights. Terminates when nearBottom goes false
1562
+ // or currentPage*50 >= totalMessages. requestAnimationFrame defers one frame
1563
+ // so layout flushes and we don't recurse on stale heights.
1564
+ requestAnimationFrame(() => maybeLoadMore(body));
1478
1565
  }
1479
1566
  /** Replace body contents with rendered rows */
1480
1567
  function renderMessages(body, accountId, items, opts = {}) {
@@ -1501,6 +1588,8 @@ function renderMessages(body, accountId, items, opts = {}) {
1501
1588
  for (const uuid of preserveSel) {
1502
1589
  body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
1503
1590
  }
1591
+ // Keep the "N selected" pill accurate after a background rebuild.
1592
+ updateBulkBar();
1504
1593
  }
1505
1594
  // A filter (flagged-only / priority-only) that hides EVERY row makes the
1506
1595
  // list look broken — identical to an empty/failed load. Bob 2026-05-27
@@ -1571,10 +1660,16 @@ function restoreSelection(body, savedUuid) {
1571
1660
  const accountId = row.dataset.accountId;
1572
1661
  const uid = Number(row.dataset.uid);
1573
1662
  if (accountId && Number.isFinite(uid)) {
1574
- // {scroll:false} programmatic restore after a sync-driven reload.
1575
- // The user might be reading rows ABOVE the focused one; do NOT yank
1576
- // their scroll back to the focused row every time the list rebuilds.
1577
- focusByIdentity(accountId, uid, { scroll: false });
1663
+ // FATE-SHARING SAFETY NET (Bob 2026-07-01: "even if there is an
1664
+ // inappropriate switch it must automatically make it visible in the
1665
+ // summary"). If this restore re-binds the SAME message already in the
1666
+ // viewer the normal case after a sync rebuild — keep the user's
1667
+ // scroll exactly where it is; they may be reading rows above/below and
1668
+ // must not be yanked. But if it targets a DIFFERENT message than the
1669
+ // one currently shown, that IS a real viewer switch: scroll the list to
1670
+ // that row so the summary and the viewer can never silently disagree.
1671
+ const switching = focusedRow?.msg?.uuid !== savedUuid;
1672
+ focusByIdentity(accountId, uid, { scroll: switching });
1578
1673
  }
1579
1674
  }
1580
1675
  /** Show a floating list of all messages in a thread when the pill is clicked.
@@ -1603,7 +1698,7 @@ export async function showThreadPopup(pillEl, headMsg) {
1603
1698
  from.textContent = msg.from?.name || msg.from?.address || "?";
1604
1699
  const date = document.createElement("span");
1605
1700
  date.className = "ml-thread-popup-date";
1606
- date.textContent = formatDate(msg.date);
1701
+ date.textContent = formatDate(effectiveDate(msg));
1607
1702
  const subject = document.createElement("span");
1608
1703
  subject.className = "ml-thread-popup-subject";
1609
1704
  subject.textContent = msg.subject || "(no subject)";
@@ -1796,7 +1891,8 @@ class MessageRow {
1796
1891
  renderStatusIcons(icons, msg);
1797
1892
  const dateText = document.createElement("span");
1798
1893
  dateText.className = "ml-date-text";
1799
- dateText.textContent = formatDate(msg.date);
1894
+ dateText.textContent = formatDate(effectiveDate(msg));
1895
+ dateText.title = dateBasis === "received" ? "Received date" : "Sent date";
1800
1896
  date.appendChild(icons);
1801
1897
  date.appendChild(dateText);
1802
1898
  row.appendChild(avatar);
@@ -2034,6 +2130,20 @@ class MessageRow {
2034
2130
  if (e.ctrlKey || e.metaKey) {
2035
2131
  this.setSelected(!this.isSelected);
2036
2132
  lastClickedRow = this.el;
2133
+ // Ctrl/Cmd-click builds a multi-selection — it MUST set
2134
+ // `multi-select-on`, or a background re-render (new mail arriving)
2135
+ // won't preserve the highlights and restoreSelection collapses the
2136
+ // whole selection down to the single focused row (Bob 2026-07-01:
2137
+ // "suddenly deselect all the messages and leave one"). Same root as
2138
+ // the old Ctrl+A gap. Drop the flag again if the toggle emptied the
2139
+ // selection so a later plain click behaves as single-select.
2140
+ if (body) {
2141
+ const n = body.querySelectorAll(".ml-row.selected").length;
2142
+ if (n >= 1)
2143
+ body.classList.add("multi-select-on");
2144
+ else
2145
+ body.classList.remove("multi-select-on");
2146
+ }
2037
2147
  }
2038
2148
  else {
2039
2149
  clearSelection();