@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.
- package/client/android-bootstrap.bundle.js +65 -15
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +378 -286
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +23 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +20 -1
- package/client/components/address-book.js +5 -1
- package/client/components/address-book.js.map +1 -1
- package/client/components/address-book.ts +5 -1
- package/client/components/folder-tree.js +31 -6
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +29 -5
- package/client/components/message-list.js +164 -54
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +159 -51
- package/client/components/message-viewer.js +7 -0
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +7 -0
- package/client/compose/compose.bundle.js +4 -4
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/index.html +4 -0
- package/client/lib/api-client.js +4 -4
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +4 -4
- package/client/lib/mailxapi.js +4 -4
- package/client/styles/components.css +20 -0
- package/package.json +9 -9
- package/packages/mailx-imap/index.d.ts +17 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +86 -4
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +85 -4
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +2 -2
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +6 -4
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +7 -5
- package/packages/mailx-service/jsonrpc.js +2 -2
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -2
- package/packages/mailx-service/sync-queue.d.ts +3 -0
- package/packages/mailx-service/sync-queue.d.ts.map +1 -1
- package/packages/mailx-service/sync-queue.js +30 -11
- package/packages/mailx-service/sync-queue.js.map +1 -1
- package/packages/mailx-service/sync-queue.ts +34 -9
- package/packages/mailx-service/sync-worker.js +51 -0
- package/packages/mailx-service/sync-worker.js.map +1 -1
- package/packages/mailx-service/sync-worker.ts +45 -0
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts +10 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +68 -7
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +70 -8
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts +1 -1
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +2 -2
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +2 -2
- package/packages/mailx-store-web/imap-web-provider.d.ts +7 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -1
- package/packages/mailx-store-web/imap-web-provider.js +12 -0
- package/packages/mailx-store-web/imap-web-provider.js.map +1 -1
- package/packages/mailx-store-web/imap-web-provider.ts +15 -0
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/sync-manager.d.ts.map +1 -1
- package/packages/mailx-store-web/sync-manager.js +12 -0
- package/packages/mailx-store-web/sync-manager.js.map +1 -1
- package/packages/mailx-store-web/sync-manager.ts +12 -0
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +53 -19
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +60 -20
- package/packages/mailx-types/index.d.ts +5 -1
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +5 -1
- package/packages/mailx-types/package.json +1 -1
|
@@ -96,6 +96,34 @@ if (typeof document !== "undefined") {
|
|
|
96
96
|
let currentSort = "date";
|
|
97
97
|
let currentSortDir: "asc" | "desc" = "desc";
|
|
98
98
|
|
|
99
|
+
// Date basis for the "date" sort + the date column: "sent" (Date: header,
|
|
100
|
+
// default — identical mail clusters, matches other clients) or "received"
|
|
101
|
+
// (per-account arrival time). Persisted so the View-menu toggle survives
|
|
102
|
+
// reloads. Bob 2026-07-01.
|
|
103
|
+
let dateBasis: "sent" | "received" =
|
|
104
|
+
(localStorage.getItem("mailx-date-basis") as "sent" | "received") || "sent";
|
|
105
|
+
|
|
106
|
+
/** The epoch-ms to DISPLAY for a row, honoring the current basis. sentDate is
|
|
107
|
+
* always populated by the store (backfilled to date), but guard anyway. */
|
|
108
|
+
function effectiveDate(msg: any): number {
|
|
109
|
+
return dateBasis === "received" ? msg.date : (msg.sentDate ?? msg.date);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Current date basis — read by app.ts to check the View-menu radio state. */
|
|
113
|
+
export function getDateBasis(): "sent" | "received" { return dateBasis; }
|
|
114
|
+
|
|
115
|
+
/** Switch the date basis, persist it, and reload the current view so the new
|
|
116
|
+
* sort + column take effect. Called by the View menu. */
|
|
117
|
+
export function setDateBasis(basis: "sent" | "received"): void {
|
|
118
|
+
if (basis === dateBasis) return;
|
|
119
|
+
dateBasis = basis;
|
|
120
|
+
try { localStorage.setItem("mailx-date-basis", basis); } catch { /* private mode */ }
|
|
121
|
+
// Cached snapshots were ordered by the old basis — clear so the reload
|
|
122
|
+
// re-sorts from the DB rather than repainting the stale order.
|
|
123
|
+
listCache.clear();
|
|
124
|
+
reloadCurrentFolder();
|
|
125
|
+
}
|
|
126
|
+
|
|
99
127
|
/** Generation counter — incremented on every load* call (loadMessages,
|
|
100
128
|
* loadUnifiedInbox, loadSearchResults). Each load captures the current
|
|
101
129
|
* value at the top, then checks before rendering — if the captured gen
|
|
@@ -194,6 +222,7 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
|
|
|
194
222
|
// must not go off screen"). The old `scrollTop < 4 → no anchor` shortcut
|
|
195
223
|
// was exactly the bug: at the top, new rows shoved the read letter off the
|
|
196
224
|
// bottom.
|
|
225
|
+
let anchoredOnSelected = false;
|
|
197
226
|
const selected = body.querySelector<HTMLElement>(".ml-row.selected");
|
|
198
227
|
if (selected) {
|
|
199
228
|
const vt = selected.offsetTop - scrollTop;
|
|
@@ -201,6 +230,7 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
|
|
|
201
230
|
if (vt > -selected.offsetHeight && vt < viewportH) {
|
|
202
231
|
anchorUuid = selected.dataset.uuid || "";
|
|
203
232
|
anchorOffsetWithinViewport = vt;
|
|
233
|
+
anchoredOnSelected = true;
|
|
204
234
|
}
|
|
205
235
|
}
|
|
206
236
|
|
|
@@ -232,7 +262,14 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
|
|
|
232
262
|
// bottom do we fall back to pinning it visible. This satisfies both
|
|
233
263
|
// "show new mail at the top" and the 2026-06-04 rule "a letter in view
|
|
234
264
|
// must stay in view (it may scroll down but must not go off screen)".
|
|
235
|
-
|
|
265
|
+
//
|
|
266
|
+
// EXCEPTION (Bob 2026-06-30: "I was looking at a message and suddenly
|
|
267
|
+
// my position changed and I was looking at a new message"): when the
|
|
268
|
+
// anchor IS the open/selected message, the user is actively reading —
|
|
269
|
+
// never reposition them. Pin the selected row exactly where it sat.
|
|
270
|
+
// The scroll-to-0 reveal only applies when nothing is open and the user
|
|
271
|
+
// is merely parked at the top of the list browsing subjects.
|
|
272
|
+
if (wasAtTop && !anchoredOnSelected && after.offsetTop < viewportH - after.offsetHeight) {
|
|
236
273
|
body.scrollTop = 0;
|
|
237
274
|
} else {
|
|
238
275
|
body.scrollTop = after.offsetTop - anchorOffsetWithinViewport;
|
|
@@ -260,8 +297,20 @@ function rememberPosition(): void {
|
|
|
260
297
|
persistPositions();
|
|
261
298
|
}
|
|
262
299
|
/** Choose the row to focus when re-entering a view with saved position.
|
|
263
|
-
* Returns the uid to focus, or null to fall back to selectFirst.
|
|
264
|
-
|
|
300
|
+
* Returns the uid to focus, or null to fall back to selectFirst.
|
|
301
|
+
*
|
|
302
|
+
* `allowNeighbor` gates the deleted-message fallback. On a FOREGROUND
|
|
303
|
+
* re-entry (user navigating INTO a view) the saved message being absent
|
|
304
|
+
* means it was deleted, so we hand focus to the nearest survivor. On a
|
|
305
|
+
* BACKGROUND reload (sync/new-mail rebuild of the current view) absent
|
|
306
|
+
* does NOT mean deleted — the message the user is READING has merely been
|
|
307
|
+
* pushed out of the loaded top-50 page by new arrivals. Switching to a
|
|
308
|
+
* neighbor there yanks the viewer onto the new arrival while the list
|
|
309
|
+
* stays put (Bob 2026-07-01: "I was looking at a letter and suddenly found
|
|
310
|
+
* myself looking at a newly arrived message; the summary did not scroll").
|
|
311
|
+
* So background callers pass allowNeighbor=false → return null → keep the
|
|
312
|
+
* current viewer untouched. */
|
|
313
|
+
function pickRestoreUid(items: any[], saved: ViewPosition, allowNeighbor = true): string | null {
|
|
265
314
|
if (!items.length) return null;
|
|
266
315
|
// Exact restore by stable uuid — globally unique, so this can never
|
|
267
316
|
// rebind the viewer to a same-uid-number message in another folder
|
|
@@ -270,6 +319,7 @@ function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
|
|
|
270
319
|
const exact = items.find(m => m.uuid && m.uuid === saved.uuid);
|
|
271
320
|
if (exact?.uuid) return exact.uuid;
|
|
272
321
|
}
|
|
322
|
+
if (!allowNeighbor) return null;
|
|
273
323
|
// Saved message is gone (deleted). Pick the next-older entry by uid —
|
|
274
324
|
// uid is roughly monotonic with arrival on IMAP/Gmail — and return ITS
|
|
275
325
|
// uuid so the restore stays uuid-keyed end to end.
|
|
@@ -729,10 +779,30 @@ export function exitMultiSelect(): void {
|
|
|
729
779
|
}
|
|
730
780
|
|
|
731
781
|
/** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
|
|
732
|
-
* toolbar now
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
|
|
782
|
+
* toolbar. This now renders just the selection COUNT (Bob 2026-07-01:
|
|
783
|
+
* "multiselect should show the count somewhere") as a floating pill at the
|
|
784
|
+
* bottom of the list panel, shown only while a multi-selection of ≥2 is
|
|
785
|
+
* active. Every selection-changing site already calls this. */
|
|
786
|
+
function updateBulkBar(): void {
|
|
787
|
+
const panel = document.getElementById("message-list");
|
|
788
|
+
if (!panel) return;
|
|
789
|
+
const body = document.getElementById("ml-body");
|
|
790
|
+
const count = body?.classList.contains("multi-select-on")
|
|
791
|
+
? body.querySelectorAll(".ml-row.selected").length : 0;
|
|
792
|
+
let pill = document.getElementById("ml-select-count");
|
|
793
|
+
if (count >= 2) {
|
|
794
|
+
if (!pill) {
|
|
795
|
+
pill = document.createElement("div");
|
|
796
|
+
pill.id = "ml-select-count";
|
|
797
|
+
pill.className = "ml-select-count";
|
|
798
|
+
panel.appendChild(pill);
|
|
799
|
+
}
|
|
800
|
+
pill.textContent = `${count} selected`;
|
|
801
|
+
pill.style.display = "";
|
|
802
|
+
} else if (pill) {
|
|
803
|
+
pill.style.display = "none";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
736
806
|
|
|
737
807
|
/** Select every shown (not filter-hidden) row AND enter multi-select mode.
|
|
738
808
|
* Shared by the selection menu's "Select all shown" and the Ctrl+A shortcut.
|
|
@@ -855,34 +925,7 @@ export function initMessageList(handler: MessageSelectHandler): void {
|
|
|
855
925
|
}
|
|
856
926
|
}, { passive: true });
|
|
857
927
|
|
|
858
|
-
body.addEventListener("scroll", () => {
|
|
859
|
-
if (loading) return;
|
|
860
|
-
// Prefetch the next page well BEFORE the bottom (was 200px). The next
|
|
861
|
-
// 50 rows are a DB query + render; triggering it only 200px out meant
|
|
862
|
-
// the user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
|
|
863
|
-
// 2026-06-18 "scroll the summary, there is a delay"). 1000px ≈ a couple
|
|
864
|
-
// screens of lead time so the page is usually ready by the time it's
|
|
865
|
-
// scrolled to. `loading` guard still prevents overlapping fetches.
|
|
866
|
-
const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
|
|
867
|
-
if (nearBottom) {
|
|
868
|
-
if (currentPage * 50 < totalMessages) {
|
|
869
|
-
loadMoreMessages();
|
|
870
|
-
} else {
|
|
871
|
-
// Diagnostic: at bottom but not loading more. Log why
|
|
872
|
-
// so the user can see whether totalMessages is wrong,
|
|
873
|
-
// a filter is on, or we've truly reached the end.
|
|
874
|
-
// Only log once per "stuck at bottom" episode.
|
|
875
|
-
if (!(body as any)._mlScrollEndLogged) {
|
|
876
|
-
(body as any)._mlScrollEndLogged = true;
|
|
877
|
-
const rows = body.querySelectorAll(".ml-row").length;
|
|
878
|
-
console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
} else {
|
|
882
|
-
// Reset the once-flag so next time we hit bottom we log again.
|
|
883
|
-
(body as any)._mlScrollEndLogged = false;
|
|
884
|
-
}
|
|
885
|
-
});
|
|
928
|
+
body.addEventListener("scroll", () => { maybeLoadMore(body); });
|
|
886
929
|
}
|
|
887
930
|
|
|
888
931
|
// Viewer signals "this row is gone server-side" via mailx-remove-stale.
|
|
@@ -1117,7 +1160,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
1117
1160
|
const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
|
|
1118
1161
|
if (!autoSelect) withScrollAnchor(body, renderCached);
|
|
1119
1162
|
else renderCached();
|
|
1120
|
-
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
1163
|
+
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
|
|
1121
1164
|
if (targetUuid) {
|
|
1122
1165
|
if (autoSelect) body.scrollTop = savedScroll;
|
|
1123
1166
|
restoreSelection(body, targetUuid);
|
|
@@ -1129,7 +1172,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
1129
1172
|
}
|
|
1130
1173
|
|
|
1131
1174
|
try {
|
|
1132
|
-
const result = await apiGetUnifiedInbox(1, 50, flaggedOnly);
|
|
1175
|
+
const result = await apiGetUnifiedInbox(1, 50, flaggedOnly, dateBasis);
|
|
1133
1176
|
if (myGen !== loadGen) return; // user moved on; drop stale response
|
|
1134
1177
|
totalMessages = result.total;
|
|
1135
1178
|
listCache.set(unifiedCacheKey, { items: result.items, total: result.total, timestamp: Date.now() });
|
|
@@ -1154,7 +1197,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
1154
1197
|
// appear above as usual.
|
|
1155
1198
|
withScrollAnchor(body, () => renderMessages(body, "", result.items, { background: !autoSelect }));
|
|
1156
1199
|
|
|
1157
|
-
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
1200
|
+
const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
|
|
1158
1201
|
if (targetUuid) {
|
|
1159
1202
|
// Only restore the saved scrollTop on a first paint (cache miss).
|
|
1160
1203
|
// After a sync-driven re-render, withScrollAnchor has already
|
|
@@ -1225,8 +1268,8 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
1225
1268
|
try { regex = new RegExp(pattern, "i"); } catch { body.innerHTML = `<div class="ml-empty">Invalid regex</div>`; return; }
|
|
1226
1269
|
|
|
1227
1270
|
const source = scope === "current" && accountId
|
|
1228
|
-
? await apiGetMessages(accountId, folderId, 1, 10000)
|
|
1229
|
-
: await apiGetUnifiedInbox(1, 10000);
|
|
1271
|
+
? await apiGetMessages(accountId, folderId, 1, 10000, false, currentSort, currentSortDir, dateBasis)
|
|
1272
|
+
: await apiGetUnifiedInbox(1, 10000, false, dateBasis);
|
|
1230
1273
|
if (myGen !== loadGen) return;
|
|
1231
1274
|
const matches = source.items.filter((m: any) =>
|
|
1232
1275
|
regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || "")
|
|
@@ -1335,7 +1378,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1335
1378
|
const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
|
|
1336
1379
|
if (!autoSelect) withScrollAnchor(body, renderCached);
|
|
1337
1380
|
else renderCached();
|
|
1338
|
-
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
1381
|
+
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
|
|
1339
1382
|
if (focusUid != null) {
|
|
1340
1383
|
// Search-as-find: jump to the specific message the user selected.
|
|
1341
1384
|
requestAnimationFrame(() => {
|
|
@@ -1356,7 +1399,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1356
1399
|
}
|
|
1357
1400
|
|
|
1358
1401
|
try {
|
|
1359
|
-
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
|
|
1402
|
+
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
|
|
1360
1403
|
// Stale-response guard: a newer load* fired while we were
|
|
1361
1404
|
// awaiting; the new view already painted. Drop this result
|
|
1362
1405
|
// silently rather than overwriting.
|
|
@@ -1397,7 +1440,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1397
1440
|
}
|
|
1398
1441
|
|
|
1399
1442
|
// Prefer saved position; otherwise default by autoSelect.
|
|
1400
|
-
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
1443
|
+
const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
|
|
1401
1444
|
if (focusUid != null) {
|
|
1402
1445
|
// Search-as-find: select the message the user picked from results.
|
|
1403
1446
|
requestAnimationFrame(() => {
|
|
@@ -1424,6 +1467,43 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1424
1467
|
}
|
|
1425
1468
|
}
|
|
1426
1469
|
|
|
1470
|
+
/** Decide whether the next page should load, and load it. Centralized so the
|
|
1471
|
+
* scroll handler and the post-load re-check share one code path.
|
|
1472
|
+
*
|
|
1473
|
+
* Why a re-check exists (Bob 2026-06-30: "scroll down near the bottom does
|
|
1474
|
+
* nothing till an upward scroll then I can scroll down again"): the browser
|
|
1475
|
+
* fires `scroll` events only while scrollTop actually changes. When the user
|
|
1476
|
+
* reaches the absolute bottom of the loaded rows, scrollTop is pinned at max,
|
|
1477
|
+
* so NO further downward scroll events fire. If the event that crossed
|
|
1478
|
+
* nearBottom arrived while a previous load was still in flight (the old
|
|
1479
|
+
* `if (loading) return` dropped it), the next page was never requested — the
|
|
1480
|
+
* list looked frozen until an upward scroll re-armed the handler. Now every
|
|
1481
|
+
* finished load re-evaluates geometry and chains the next page if the user is
|
|
1482
|
+
* still sitting near the bottom, so reaching the end is self-driving. */
|
|
1483
|
+
function maybeLoadMore(body: HTMLElement): void {
|
|
1484
|
+
if (loading) return;
|
|
1485
|
+
const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
|
|
1486
|
+
if (!nearBottom) {
|
|
1487
|
+
// Reset the once-flag so next time we hit bottom we log again.
|
|
1488
|
+
(body as any)._mlScrollEndLogged = false;
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (currentPage * 50 < totalMessages) {
|
|
1492
|
+
// Prefetch the next page well BEFORE the bottom (1000px lead). The next
|
|
1493
|
+
// 50 rows are a DB query + render; triggering only 200px out meant the
|
|
1494
|
+
// user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
|
|
1495
|
+
// 2026-06-18 "scroll the summary, there is a delay").
|
|
1496
|
+
loadMoreMessages();
|
|
1497
|
+
} else if (!(body as any)._mlScrollEndLogged) {
|
|
1498
|
+
// Diagnostic: at bottom but not loading more. Log why so we can see
|
|
1499
|
+
// whether totalMessages is wrong, a filter is on, or we've truly
|
|
1500
|
+
// reached the end. Once per "stuck at bottom" episode.
|
|
1501
|
+
(body as any)._mlScrollEndLogged = true;
|
|
1502
|
+
const rows = body.querySelectorAll(".ml-row").length;
|
|
1503
|
+
console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1427
1507
|
async function loadMoreMessages(): Promise<void> {
|
|
1428
1508
|
const body = document.getElementById("ml-body");
|
|
1429
1509
|
if (!body) return;
|
|
@@ -1445,8 +1525,8 @@ async function loadMoreMessages(): Promise<void> {
|
|
|
1445
1525
|
const result = searchMode
|
|
1446
1526
|
? await searchMessages(currentSearchQuery, currentPage)
|
|
1447
1527
|
: unifiedMode
|
|
1448
|
-
? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly)
|
|
1449
|
-
: await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
|
|
1528
|
+
? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly, dateBasis)
|
|
1529
|
+
: await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
|
|
1450
1530
|
// Append to state
|
|
1451
1531
|
const current = state.getMessages();
|
|
1452
1532
|
state.setMessages([...current, ...result.items]);
|
|
@@ -1461,6 +1541,13 @@ async function loadMoreMessages(): Promise<void> {
|
|
|
1461
1541
|
} finally {
|
|
1462
1542
|
loading = false;
|
|
1463
1543
|
}
|
|
1544
|
+
// Re-evaluate: if the user is STILL near the bottom (they kept their finger
|
|
1545
|
+
// pinned at the end, or the page that loaded was shorter than the viewport),
|
|
1546
|
+
// chain the next page. Geometry has settled after the synchronous append
|
|
1547
|
+
// above, so this reads true heights. Terminates when nearBottom goes false
|
|
1548
|
+
// or currentPage*50 >= totalMessages. requestAnimationFrame defers one frame
|
|
1549
|
+
// so layout flushes and we don't recurse on stale heights.
|
|
1550
|
+
requestAnimationFrame(() => maybeLoadMore(body));
|
|
1464
1551
|
}
|
|
1465
1552
|
|
|
1466
1553
|
/** Replace body contents with rendered rows */
|
|
@@ -1487,6 +1574,8 @@ function renderMessages(body: HTMLElement, accountId: string, items: any[], opts
|
|
|
1487
1574
|
for (const uuid of preserveSel) {
|
|
1488
1575
|
body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
|
|
1489
1576
|
}
|
|
1577
|
+
// Keep the "N selected" pill accurate after a background rebuild.
|
|
1578
|
+
updateBulkBar();
|
|
1490
1579
|
}
|
|
1491
1580
|
// A filter (flagged-only / priority-only) that hides EVERY row makes the
|
|
1492
1581
|
// list look broken — identical to an empty/failed load. Bob 2026-05-27
|
|
@@ -1552,10 +1641,16 @@ function restoreSelection(body: HTMLElement, savedUuid: string | null | undefine
|
|
|
1552
1641
|
const accountId = row.dataset.accountId;
|
|
1553
1642
|
const uid = Number(row.dataset.uid);
|
|
1554
1643
|
if (accountId && Number.isFinite(uid)) {
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
|
|
1644
|
+
// FATE-SHARING SAFETY NET (Bob 2026-07-01: "even if there is an
|
|
1645
|
+
// inappropriate switch it must automatically make it visible in the
|
|
1646
|
+
// summary"). If this restore re-binds the SAME message already in the
|
|
1647
|
+
// viewer — the normal case after a sync rebuild — keep the user's
|
|
1648
|
+
// scroll exactly where it is; they may be reading rows above/below and
|
|
1649
|
+
// must not be yanked. But if it targets a DIFFERENT message than the
|
|
1650
|
+
// one currently shown, that IS a real viewer switch: scroll the list to
|
|
1651
|
+
// that row so the summary and the viewer can never silently disagree.
|
|
1652
|
+
const switching = focusedRow?.msg?.uuid !== savedUuid;
|
|
1653
|
+
focusByIdentity(accountId, uid, { scroll: switching });
|
|
1559
1654
|
}
|
|
1560
1655
|
}
|
|
1561
1656
|
|
|
@@ -1580,7 +1675,7 @@ export async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promis
|
|
|
1580
1675
|
from.textContent = msg.from?.name || msg.from?.address || "?";
|
|
1581
1676
|
const date = document.createElement("span");
|
|
1582
1677
|
date.className = "ml-thread-popup-date";
|
|
1583
|
-
date.textContent = formatDate(msg
|
|
1678
|
+
date.textContent = formatDate(effectiveDate(msg));
|
|
1584
1679
|
const subject = document.createElement("span");
|
|
1585
1680
|
subject.className = "ml-thread-popup-subject";
|
|
1586
1681
|
subject.textContent = msg.subject || "(no subject)";
|
|
@@ -1772,7 +1867,8 @@ class MessageRow {
|
|
|
1772
1867
|
renderStatusIcons(icons, msg);
|
|
1773
1868
|
const dateText = document.createElement("span");
|
|
1774
1869
|
dateText.className = "ml-date-text";
|
|
1775
|
-
dateText.textContent = formatDate(msg
|
|
1870
|
+
dateText.textContent = formatDate(effectiveDate(msg));
|
|
1871
|
+
dateText.title = dateBasis === "received" ? "Received date" : "Sent date";
|
|
1776
1872
|
date.appendChild(icons);
|
|
1777
1873
|
date.appendChild(dateText);
|
|
1778
1874
|
|
|
@@ -2010,6 +2106,18 @@ class MessageRow {
|
|
|
2010
2106
|
if (e.ctrlKey || e.metaKey) {
|
|
2011
2107
|
this.setSelected(!this.isSelected);
|
|
2012
2108
|
lastClickedRow = this.el;
|
|
2109
|
+
// Ctrl/Cmd-click builds a multi-selection — it MUST set
|
|
2110
|
+
// `multi-select-on`, or a background re-render (new mail arriving)
|
|
2111
|
+
// won't preserve the highlights and restoreSelection collapses the
|
|
2112
|
+
// whole selection down to the single focused row (Bob 2026-07-01:
|
|
2113
|
+
// "suddenly deselect all the messages and leave one"). Same root as
|
|
2114
|
+
// the old Ctrl+A gap. Drop the flag again if the toggle emptied the
|
|
2115
|
+
// selection so a later plain click behaves as single-select.
|
|
2116
|
+
if (body) {
|
|
2117
|
+
const n = body.querySelectorAll(".ml-row.selected").length;
|
|
2118
|
+
if (n >= 1) body.classList.add("multi-select-on");
|
|
2119
|
+
else body.classList.remove("multi-select-on");
|
|
2120
|
+
}
|
|
2013
2121
|
} else {
|
|
2014
2122
|
clearSelection();
|
|
2015
2123
|
focusRow(this);
|
|
@@ -762,6 +762,13 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
762
762
|
if (name) {
|
|
763
763
|
items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
|
|
764
764
|
}
|
|
765
|
+
items.push({
|
|
766
|
+
label: `Open in contacts: ${addr.address}`,
|
|
767
|
+
action: async () => {
|
|
768
|
+
const { openAddressBook } = await import("./address-book.js");
|
|
769
|
+
await openAddressBook(addr.address);
|
|
770
|
+
},
|
|
771
|
+
});
|
|
765
772
|
items.push({
|
|
766
773
|
label: `Add to contacts: ${addr.address}`,
|
|
767
774
|
action: async () => {
|