@bobfrankston/rmfmail 1.2.50 → 1.2.65
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 +63 -2
- package/client/android-bootstrap.bundle.js.map +4 -4
- package/client/app.bundle.js +417 -63
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +182 -24
- package/client/app.js.map +1 -1
- package/client/app.ts +166 -21
- package/client/components/calendar-sidebar.js +5 -5
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +5 -5
- package/client/components/folder-tree.js +81 -0
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +70 -0
- package/client/components/message-list.js +282 -38
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +264 -36
- package/client/compose/compose.bundle.js +70 -14
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.css +28 -7
- package/client/compose/compose.js +64 -4
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +66 -4
- package/client/compose/editor.js +23 -7
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +23 -7
- package/client/help/search-help.js +4 -3
- package/client/help/search-help.js.map +1 -1
- package/client/help/search-help.ts +4 -3
- package/client/index.html +5 -1
- package/client/lib/api-client.js +4 -2
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +4 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/lib/rmf-tiny.js +39 -6
- package/client/styles/components.css +6 -0
- package/docs/azure.md +58 -0
- package/docs/config-help.md +131 -0
- package/docs/edit-in-word-docx.md +98 -0
- package/docs/host-abstraction-plan.md +169 -0
- package/docs/local-first-plan.md +303 -0
- package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/docs/outlook.md +80 -18
- package/docs/outlook.txt +35 -0
- package/docs/rules-design.md +172 -0
- package/package.json +7 -7
- package/packages/mailx-imap/index.d.ts +48 -1
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +320 -68
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +311 -65
- package/packages/mailx-imap/microsoft-credentials.json +8 -0
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
- package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
- package/packages/mailx-imap/providers/outlook-api.js +7 -0
- package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
- package/packages/mailx-service/index.d.ts +7 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +8 -8
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +8 -7
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +1 -1
- package/packages/mailx-settings/docs/azure.md +58 -0
- package/packages/mailx-settings/docs/config-help.md +131 -0
- package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
- package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
- package/packages/mailx-settings/docs/local-first-plan.md +303 -0
- package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/packages/mailx-settings/docs/outlook.md +80 -18
- package/packages/mailx-settings/docs/rules-design.md +172 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +9 -8
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +9 -8
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts +36 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +100 -6
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +100 -6
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-types/contact-rules.d.ts +1 -1
- package/packages/mailx-types/contact-rules.js +1 -1
- package/packages/mailx-types/contact-rules.js.map +1 -1
- package/packages/mailx-types/contact-rules.jsonc +1 -1
- package/packages/mailx-types/contact-rules.ts +1 -1
- package/packages/mailx-types/package.json +1 -1
|
@@ -51,6 +51,16 @@ function applyLiveFilter(body) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
let currentSearchQuery = "";
|
|
54
|
+
/** Empty-list placeholder text. When a search is active, name the query so an
|
|
55
|
+
* empty list reads as "nothing matched THIS search" rather than a bare,
|
|
56
|
+
* alarming "No messages" (Bob 2026-06-25). Covers paths other than the
|
|
57
|
+
* search loader itself — e.g. deleting the last surviving search hit. */
|
|
58
|
+
function emptyListMessage() {
|
|
59
|
+
if (searchMode && currentSearchQuery) {
|
|
60
|
+
return `No messages found by "${escapeHtmlText(currentSearchQuery)}"`;
|
|
61
|
+
}
|
|
62
|
+
return "No messages";
|
|
63
|
+
}
|
|
54
64
|
// Remember the pre-search view mode so clearSearchMode() can restore it.
|
|
55
65
|
// Without this, searching from "All Inboxes" loses the unified-mode flag
|
|
56
66
|
// (loadSearchResults sets unifiedMode=false), and clearing the search
|
|
@@ -387,6 +397,166 @@ export function setRowSeen(accountId, uid, yes) {
|
|
|
387
397
|
if (row)
|
|
388
398
|
row.setUnreadClass(!yes);
|
|
389
399
|
}
|
|
400
|
+
/** Resolve currently-selected rows to MessageRow objects (skips any not in
|
|
401
|
+
* the loaded DOM). */
|
|
402
|
+
function selectedRows() {
|
|
403
|
+
const out = [];
|
|
404
|
+
for (const s of getSelectedMessages()) {
|
|
405
|
+
const row = rowByKey.get(rowKey(s.accountId, s.uid));
|
|
406
|
+
if (row)
|
|
407
|
+
out.push(row);
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
/** Every row currently shown (visible = matches search + view filters, and
|
|
412
|
+
* loaded — the list lazy-loads). */
|
|
413
|
+
function visibleRowObjs() {
|
|
414
|
+
const body = document.getElementById("ml-body");
|
|
415
|
+
if (!body)
|
|
416
|
+
return [];
|
|
417
|
+
return Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
|
|
418
|
+
.map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
|
|
419
|
+
.filter((r) => !!r);
|
|
420
|
+
}
|
|
421
|
+
/** The set a bulk action targets: the SELECTED rows if any are marked,
|
|
422
|
+
* otherwise ALL shown rows. This is the "act on selected, else all filtered"
|
|
423
|
+
* rule Bob asked for (2026-06-24). */
|
|
424
|
+
function bulkTargets() {
|
|
425
|
+
const sel = selectedRows();
|
|
426
|
+
return sel.length > 0 ? sel : visibleRowObjs();
|
|
427
|
+
}
|
|
428
|
+
/** Flag / unflag a set of rows. Captures the prior flagged state per row and
|
|
429
|
+
* emits `mailx-flagged-batch` so app.ts can register ONE Ctrl+Z undo entry
|
|
430
|
+
* for the whole batch (Bob 2026-06-24: bulk ops need undo for safety). */
|
|
431
|
+
async function bulkSetFlagged(rows, flagged) {
|
|
432
|
+
const undo = [];
|
|
433
|
+
await Promise.all(rows.map(async (row) => {
|
|
434
|
+
// Read current state from the VISIBLE class via row.isFlagged — NOT
|
|
435
|
+
// flaggedOf(row.msg). The two can drift, and a stale `msg.flags` made
|
|
436
|
+
// Unflag skip flagged rows as "already unflagged" (Bob 2026-06-24
|
|
437
|
+
// "unflag is not removing the flag"). The class is the source of truth
|
|
438
|
+
// the single-star toggle already trusts.
|
|
439
|
+
const prev = row.isFlagged;
|
|
440
|
+
if (prev === flagged)
|
|
441
|
+
return; // already in the target state
|
|
442
|
+
const prevFlags = [...(row.msg.flags || [])];
|
|
443
|
+
row.isFlagged = flagged; // setter: updates class + msg.flags
|
|
444
|
+
try {
|
|
445
|
+
await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
446
|
+
state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
447
|
+
undo.push({ accountId: row.accountId, uid: row.msg.uid, prevFlagged: prev });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
row.msg.flags = prevFlags; // revert on server failure
|
|
451
|
+
row.setFlaggedClass(prev);
|
|
452
|
+
}
|
|
453
|
+
}));
|
|
454
|
+
if (undo.length)
|
|
455
|
+
document.dispatchEvent(new CustomEvent("mailx-flagged-batch", { detail: undo }));
|
|
456
|
+
}
|
|
457
|
+
/** Mark a set of rows read / unread. Low-risk + trivially reversible, so no
|
|
458
|
+
* undo entry (unlike flag, move, delete). State read from the class too. */
|
|
459
|
+
async function bulkSetSeen(rows, seen) {
|
|
460
|
+
await Promise.all(rows.map(async (row) => {
|
|
461
|
+
const prev = row.isSeen;
|
|
462
|
+
if (prev === seen)
|
|
463
|
+
return;
|
|
464
|
+
const prevFlags = [...(row.msg.flags || [])];
|
|
465
|
+
row.isSeen = seen; // setter: updates class + msg.flags
|
|
466
|
+
try {
|
|
467
|
+
await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
468
|
+
state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
row.msg.flags = prevFlags;
|
|
472
|
+
row.setUnreadClass(!prev);
|
|
473
|
+
}
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
/** Move every selected message (of the first selected row's account) to a
|
|
477
|
+
* picked folder. Local-first: optimistic remove + fire-and-forget IPC, same
|
|
478
|
+
* as the row context menu's "Move to folder…". */
|
|
479
|
+
async function bulkMoveSelection() {
|
|
480
|
+
const targets = bulkTargets();
|
|
481
|
+
if (targets.length === 0)
|
|
482
|
+
return;
|
|
483
|
+
const accountId = targets[0].accountId;
|
|
484
|
+
const same = targets.filter(r => r.accountId === accountId);
|
|
485
|
+
const pick = await pickFolder(accountId, { excludeFolderIds: [same[0].msg.folderId] });
|
|
486
|
+
if (!pick)
|
|
487
|
+
return;
|
|
488
|
+
const uids = same.map(r => r.msg.uid);
|
|
489
|
+
removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
|
|
490
|
+
const statusSync = document.getElementById("status-sync");
|
|
491
|
+
if (statusSync)
|
|
492
|
+
statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? "s" : ""} to ${pick.folderName}…`;
|
|
493
|
+
apiMoveMessages(accountId, uids, pick.folderId)
|
|
494
|
+
.then(() => { if (statusSync)
|
|
495
|
+
statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })
|
|
496
|
+
.catch((err) => { if (statusSync)
|
|
497
|
+
statusSync.textContent = `Move sync issue: ${err?.message || err}`; });
|
|
498
|
+
exitMultiSelect();
|
|
499
|
+
}
|
|
500
|
+
/** Toolbar "Filtered" menu — home for bulk operations. Two concepts, kept
|
|
501
|
+
* separate (Bob 2026-06-24): SHOWN = rows the current folder/search/filters
|
|
502
|
+
* make visible (lazy-loaded, so it's what's loaded now); SELECTED = rows
|
|
503
|
+
* you've clicked. Actions auto-target: if anything is SELECTED they act on
|
|
504
|
+
* that, otherwise on ALL SHOWN — the header says which is in effect. Delete
|
|
505
|
+
* and Mark-as-spam are omitted: the toolbar's own buttons already do those on
|
|
506
|
+
* the selection. Flag/unflag and move are Ctrl+Z-undoable. */
|
|
507
|
+
export async function showSelectionMenu(x, y) {
|
|
508
|
+
const { showContextMenu } = await import("./context-menu.js");
|
|
509
|
+
const body = document.getElementById("ml-body");
|
|
510
|
+
const visibleRows = body
|
|
511
|
+
? Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
|
|
512
|
+
: [];
|
|
513
|
+
const enterMulti = () => body?.classList.add("multi-select-on");
|
|
514
|
+
const shownCount = visibleRows.length;
|
|
515
|
+
const selCount = getSelectedMessages().length;
|
|
516
|
+
const hasShown = shownCount > 0;
|
|
517
|
+
// Auto-target: selected if any, else all shown. The action labels carry
|
|
518
|
+
// the effective target count so there's no ambiguity about what runs.
|
|
519
|
+
const targetN = selCount > 0 ? selCount : shownCount;
|
|
520
|
+
const onWhat = selCount > 0 ? `selected (${selCount})` : `all shown (${shownCount})`;
|
|
521
|
+
const canAct = targetN > 0;
|
|
522
|
+
const items = [
|
|
523
|
+
{ label: `Acting on ${onWhat}`, action: () => { }, disabled: true },
|
|
524
|
+
{ label: "", action: () => { }, separator: true },
|
|
525
|
+
{
|
|
526
|
+
label: `Select all shown (${shownCount})`,
|
|
527
|
+
action: () => { if (!body)
|
|
528
|
+
return; enterMulti(); for (const r of visibleRows)
|
|
529
|
+
r.classList.add("selected"); lastClickedRow = visibleRows[visibleRows.length - 1] || null; updateBulkBar(); },
|
|
530
|
+
disabled: !hasShown,
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
label: "Select flagged ★ among shown",
|
|
534
|
+
action: () => { if (!body)
|
|
535
|
+
return; enterMulti(); for (const r of visibleRows)
|
|
536
|
+
r.classList.toggle("selected", r.classList.contains("flagged")); lastClickedRow = null; updateBulkBar(); },
|
|
537
|
+
disabled: !hasShown,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
label: "Invert selection among shown",
|
|
541
|
+
action: () => { if (!body)
|
|
542
|
+
return; enterMulti(); for (const r of visibleRows)
|
|
543
|
+
r.classList.toggle("selected"); updateBulkBar(); },
|
|
544
|
+
disabled: !hasShown,
|
|
545
|
+
},
|
|
546
|
+
{ label: `Deselect all (${selCount})`, action: () => exitMultiSelect(), disabled: selCount === 0 },
|
|
547
|
+
{ label: "", action: () => { }, separator: true },
|
|
548
|
+
// Actions — auto-targeted (selected else all shown). No count on the
|
|
549
|
+
// verb itself (a number on "Flag" reads as ambiguous, Bob 2026-06-24);
|
|
550
|
+
// the "Acting on …" header above carries the target count.
|
|
551
|
+
{ label: "Flag", action: () => bulkSetFlagged(bulkTargets(), true), disabled: !canAct },
|
|
552
|
+
{ label: "Unflag", action: () => bulkSetFlagged(bulkTargets(), false), disabled: !canAct },
|
|
553
|
+
{ label: "Mark read", action: () => bulkSetSeen(bulkTargets(), true), disabled: !canAct },
|
|
554
|
+
{ label: "Mark unread", action: () => bulkSetSeen(bulkTargets(), false), disabled: !canAct },
|
|
555
|
+
{ label: "", action: () => { }, separator: true },
|
|
556
|
+
{ label: "Move to folder…", action: () => bulkMoveSelection(), disabled: !canAct },
|
|
557
|
+
];
|
|
558
|
+
showContextMenu(x, y, items);
|
|
559
|
+
}
|
|
390
560
|
/** Scroll the focused row into view. Wired to a keyboard shortcut so the
|
|
391
561
|
* user can recover after scrolling the list away from the preview.
|
|
392
562
|
*
|
|
@@ -815,7 +985,7 @@ export function removeMessagesAndReconcile(uids) {
|
|
|
815
985
|
}
|
|
816
986
|
}
|
|
817
987
|
if (state.getMessages().length === 0) {
|
|
818
|
-
body.innerHTML = `<div class="ml-empty"
|
|
988
|
+
body.innerHTML = `<div class="ml-empty">${emptyListMessage()}</div>`;
|
|
819
989
|
}
|
|
820
990
|
}
|
|
821
991
|
if (outcome.focusedWasRemoved) {
|
|
@@ -908,10 +1078,17 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
908
1078
|
if (cached) {
|
|
909
1079
|
totalMessages = cached.total;
|
|
910
1080
|
state.setMessages(cached.items);
|
|
911
|
-
|
|
1081
|
+
// See loadMessages: anchor the cache render on background reloads so a
|
|
1082
|
+
// scrolled reader isn't yanked to the top when new mail arrives.
|
|
1083
|
+
const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
|
|
1084
|
+
if (!autoSelect)
|
|
1085
|
+
withScrollAnchor(body, renderCached);
|
|
1086
|
+
else
|
|
1087
|
+
renderCached();
|
|
912
1088
|
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
913
1089
|
if (targetUuid) {
|
|
914
|
-
|
|
1090
|
+
if (autoSelect)
|
|
1091
|
+
body.scrollTop = savedScroll;
|
|
915
1092
|
restoreSelection(body, targetUuid);
|
|
916
1093
|
}
|
|
917
1094
|
else if (autoSelect) {
|
|
@@ -1034,7 +1211,7 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
1034
1211
|
totalMessages = matches.length;
|
|
1035
1212
|
state.setMessages(matches);
|
|
1036
1213
|
if (matches.length === 0) {
|
|
1037
|
-
body.innerHTML = `<div class="ml-empty">No
|
|
1214
|
+
body.innerHTML = `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"</div>`;
|
|
1038
1215
|
return;
|
|
1039
1216
|
}
|
|
1040
1217
|
body.innerHTML = "";
|
|
@@ -1060,7 +1237,7 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
1060
1237
|
if (result.items.length === 0) {
|
|
1061
1238
|
state.setMessages([]);
|
|
1062
1239
|
body.innerHTML = partialNote +
|
|
1063
|
-
`<div class="ml-empty">No
|
|
1240
|
+
`<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"${partial ? " in the folders that responded" : ""}</div>`;
|
|
1064
1241
|
return;
|
|
1065
1242
|
}
|
|
1066
1243
|
state.setMessages(result.items);
|
|
@@ -1074,7 +1251,15 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
1074
1251
|
body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
|
|
1075
1252
|
}
|
|
1076
1253
|
}
|
|
1077
|
-
|
|
1254
|
+
/** Search-as-find: leave search and reveal a specific message in its own
|
|
1255
|
+
* folder. Loads the folder list, selects the message, and scrolls it into
|
|
1256
|
+
* view. Used when the user closes search while a result is selected — they
|
|
1257
|
+
* "stay at" that message in its real folder instead of bouncing back to the
|
|
1258
|
+
* pre-search view (Bob 2026-06-25). */
|
|
1259
|
+
export function revealMessage(accountId, folderId, uid, specialUse = "") {
|
|
1260
|
+
loadMessages(accountId, folderId, 1, specialUse, true, uid);
|
|
1261
|
+
}
|
|
1262
|
+
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true, focusUid) {
|
|
1078
1263
|
const myGen = ++loadGen;
|
|
1079
1264
|
searchMode = false;
|
|
1080
1265
|
unifiedMode = false;
|
|
@@ -1117,11 +1302,31 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
1117
1302
|
if (cached) {
|
|
1118
1303
|
totalMessages = cached.total;
|
|
1119
1304
|
state.setMessages(cached.items);
|
|
1120
|
-
|
|
1305
|
+
// Background reload (new mail / sync): a bare renderMessages rebuilds
|
|
1306
|
+
// innerHTML and resets scrollTop to 0, which then makes the follow-up
|
|
1307
|
+
// network anchor think the user is at the top and reveal new mail there
|
|
1308
|
+
// — yanking a scrolled-down reader to the top (Bob 2026-06-24 "the list
|
|
1309
|
+
// jumps when a new message is added ... must not move while I'm
|
|
1310
|
+
// viewing it"). Anchor the cache render too so the visible rows stay put.
|
|
1311
|
+
const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
|
|
1312
|
+
if (!autoSelect)
|
|
1313
|
+
withScrollAnchor(body, renderCached);
|
|
1314
|
+
else
|
|
1315
|
+
renderCached();
|
|
1121
1316
|
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
1122
|
-
if (
|
|
1317
|
+
if (focusUid != null) {
|
|
1318
|
+
// Search-as-find: jump to the specific message the user selected.
|
|
1123
1319
|
requestAnimationFrame(() => {
|
|
1124
|
-
|
|
1320
|
+
if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect)
|
|
1321
|
+
selectFirst(body);
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
else if (targetUuid) {
|
|
1325
|
+
requestAnimationFrame(() => {
|
|
1326
|
+
// Folder switch restores saved pixels; a background reload was
|
|
1327
|
+
// already placed by the anchor — don't override it.
|
|
1328
|
+
if (autoSelect)
|
|
1329
|
+
body.scrollTop = savedScroll;
|
|
1125
1330
|
restoreSelection(body, targetUuid);
|
|
1126
1331
|
});
|
|
1127
1332
|
}
|
|
@@ -1173,7 +1378,16 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
1173
1378
|
}
|
|
1174
1379
|
// Prefer saved position; otherwise default by autoSelect.
|
|
1175
1380
|
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
1176
|
-
if (
|
|
1381
|
+
if (focusUid != null) {
|
|
1382
|
+
// Search-as-find: select the message the user picked from results.
|
|
1383
|
+
requestAnimationFrame(() => {
|
|
1384
|
+
if (myGen !== loadGen)
|
|
1385
|
+
return;
|
|
1386
|
+
if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect)
|
|
1387
|
+
selectFirst(body);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
else if (targetUuid) {
|
|
1177
1391
|
requestAnimationFrame(() => {
|
|
1178
1392
|
if (myGen !== loadGen)
|
|
1179
1393
|
return;
|
|
@@ -1204,6 +1418,15 @@ async function loadMoreMessages() {
|
|
|
1204
1418
|
return;
|
|
1205
1419
|
loading = true;
|
|
1206
1420
|
currentPage++;
|
|
1421
|
+
// Diagnostics for the "scroll near the bottom does nothing" report
|
|
1422
|
+
// (Bob 2026-06-22: position-dependent, "go up a tad and it works"). The
|
|
1423
|
+
// suspect is a loadMore that doesn't grow scrollHeight — duplicate/empty
|
|
1424
|
+
// page, end-of-list overshoot — leaving the user pinned. Capture
|
|
1425
|
+
// before/after geometry + how many rows the page actually added so the
|
|
1426
|
+
// log shows whether the list grew, stalled, or the page came back empty.
|
|
1427
|
+
const t0 = Date.now();
|
|
1428
|
+
const heightBefore = body.scrollHeight;
|
|
1429
|
+
const rowsBefore = body.querySelectorAll(".ml-row").length;
|
|
1207
1430
|
try {
|
|
1208
1431
|
const flaggedOnly = body.classList.contains("flagged-only");
|
|
1209
1432
|
const result = searchMode
|
|
@@ -1215,6 +1438,11 @@ async function loadMoreMessages() {
|
|
|
1215
1438
|
const current = state.getMessages();
|
|
1216
1439
|
state.setMessages([...current, ...result.items]);
|
|
1217
1440
|
appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
|
|
1441
|
+
const rowsAfter = body.querySelectorAll(".ml-row").length;
|
|
1442
|
+
const heightAfter = body.scrollHeight;
|
|
1443
|
+
const elapsed = Date.now() - t0;
|
|
1444
|
+
const grew = heightAfter > heightBefore;
|
|
1445
|
+
console.log(` [ml-loadmore] page=${currentPage} got=${result.items?.length ?? 0} rows ${rowsBefore}->${rowsAfter} height ${heightBefore}->${heightAfter} grew=${grew} total=${totalMessages} ${elapsed}ms${grew ? "" : " ⚠ NO GROWTH — scroll will appear stuck"}`);
|
|
1218
1446
|
}
|
|
1219
1447
|
catch (e) {
|
|
1220
1448
|
console.error(`Load more error: ${e.message}`);
|
|
@@ -1225,12 +1453,30 @@ async function loadMoreMessages() {
|
|
|
1225
1453
|
}
|
|
1226
1454
|
/** Replace body contents with rendered rows */
|
|
1227
1455
|
function renderMessages(body, accountId, items, opts = {}) {
|
|
1456
|
+
// MULTI-SELECT SURVIVAL: replaceChildren below builds fresh rows with no
|
|
1457
|
+
// `.selected`, so a sync/new-mail rebuild would collapse an active
|
|
1458
|
+
// multi-selection down to the single focused row restoreSelection re-binds
|
|
1459
|
+
// (Bob 2026-06-22: "selected multiple NMLS spam, suddenly seeing only one —
|
|
1460
|
+
// a new message arrived and confused you"). The `multi-select-on` class
|
|
1461
|
+
// lives on the body and survives the rebuild, so when it's set we snapshot
|
|
1462
|
+
// the selected rows' stable uuids and re-apply `.selected` to whichever
|
|
1463
|
+
// survive the new render. Keyed on uuid, so a folder switch (different
|
|
1464
|
+
// uuids) matches nothing and correctly drops the stale highlight.
|
|
1465
|
+
const preserveSel = body.classList.contains("multi-select-on")
|
|
1466
|
+
? new Set(Array.from(body.querySelectorAll(".ml-row.selected"))
|
|
1467
|
+
.map(r => r.dataset.uuid || "").filter(Boolean))
|
|
1468
|
+
: null;
|
|
1228
1469
|
const fragment = document.createDocumentFragment();
|
|
1229
1470
|
const tempDiv = document.createElement("div");
|
|
1230
1471
|
appendMessages(tempDiv, accountId, items);
|
|
1231
1472
|
while (tempDiv.firstChild)
|
|
1232
1473
|
fragment.appendChild(tempDiv.firstChild);
|
|
1233
1474
|
body.replaceChildren(fragment);
|
|
1475
|
+
if (preserveSel && preserveSel.size > 0) {
|
|
1476
|
+
for (const uuid of preserveSel) {
|
|
1477
|
+
body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1234
1480
|
// A filter (flagged-only / priority-only) that hides EVERY row makes the
|
|
1235
1481
|
// list look broken — identical to an empty/failed load. Bob 2026-05-27
|
|
1236
1482
|
// lost an afternoon to this: the flagged-only filter was stuck on (it
|
|
@@ -1674,6 +1920,19 @@ class MessageRow {
|
|
|
1674
1920
|
}
|
|
1675
1921
|
onFlagClick(e) {
|
|
1676
1922
|
e.stopPropagation();
|
|
1923
|
+
// Multi-select: clicking the ★ on one of several highlighted rows acts
|
|
1924
|
+
// on the WHOLE selection, not just this row — same as the context-menu
|
|
1925
|
+
// Flag/Unflag (Bob 2026-06-26: "clear the * only clears one not the
|
|
1926
|
+
// selection"). Target state is the toggle of the clicked row, so a
|
|
1927
|
+
// flagged row clears the lot and an unflagged row flags the lot.
|
|
1928
|
+
const body = this.el.parentElement;
|
|
1929
|
+
if (body?.classList.contains("multi-select-on") && this.el.classList.contains("selected")) {
|
|
1930
|
+
const sel = selectedRows();
|
|
1931
|
+
if (sel.length > 1) {
|
|
1932
|
+
void bulkSetFlagged(sel, !this.el.classList.contains("flagged"));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1677
1936
|
// Local-first: flip the visual class + in-memory flag array NOW so the
|
|
1678
1937
|
// star toggles under the cursor with zero latency, then mirror to the
|
|
1679
1938
|
// server in the background. The earlier `await updateFlags(...)`
|
|
@@ -1854,38 +2113,23 @@ class MessageRow {
|
|
|
1854
2113
|
const accountId = this.accountId;
|
|
1855
2114
|
const msg = this.msg;
|
|
1856
2115
|
const self = this;
|
|
2116
|
+
// Count of selected rows — when >1, the read/flag actions apply to the
|
|
2117
|
+
// whole selection and the label says so (e.g. "Unflag (5)"). The row
|
|
2118
|
+
// menu's fallback (nothing selected) is the clicked row, NOT all shown.
|
|
2119
|
+
const selCount = getSelectedMessages().length;
|
|
2120
|
+
const countSfx = selCount > 1 ? ` (${selCount})` : "";
|
|
2121
|
+
const rowTargets = () => { const s = selectedRows(); return s.length > 0 ? s : [self]; };
|
|
1857
2122
|
const items = [
|
|
1858
2123
|
{
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
// debounce; the new flag array is computed via the
|
|
1865
|
-
// typed setter and sent to the server.
|
|
1866
|
-
const probe = { flags: [...(msg.flags || [])] };
|
|
1867
|
-
setSeen(probe, !isSeen);
|
|
1868
|
-
try {
|
|
1869
|
-
await updateFlags(accountId, msg.uid, probe.flags);
|
|
1870
|
-
msg.flags = probe.flags;
|
|
1871
|
-
state.updateMessageFlags(accountId, msg.uid, probe.flags);
|
|
1872
|
-
self.setUnreadClass(isSeen);
|
|
1873
|
-
}
|
|
1874
|
-
catch { /* ignore */ }
|
|
1875
|
-
},
|
|
2124
|
+
// Target state = inverse of what the menu showed, matching what
|
|
2125
|
+
// the user clicked. Visual-state-based labels stay coherent with
|
|
2126
|
+
// the auto-mark-as-read debounce. Applies to the whole selection.
|
|
2127
|
+
label: (isSeen ? "Mark unread" : "Mark read") + countSfx,
|
|
2128
|
+
action: () => bulkSetSeen(rowTargets(), !isSeen),
|
|
1876
2129
|
},
|
|
1877
2130
|
{
|
|
1878
|
-
label: isFlagged ? "Unflag" : "Flag",
|
|
1879
|
-
action:
|
|
1880
|
-
const probe = { flags: [...(msg.flags || [])] };
|
|
1881
|
-
setFlagged(probe, !isFlagged);
|
|
1882
|
-
try {
|
|
1883
|
-
await updateFlags(accountId, msg.uid, probe.flags);
|
|
1884
|
-
msg.flags = probe.flags;
|
|
1885
|
-
self.setFlaggedClass(!isFlagged);
|
|
1886
|
-
}
|
|
1887
|
-
catch { /* ignore */ }
|
|
1888
|
-
},
|
|
2131
|
+
label: (isFlagged ? "Unflag" : "Flag") + countSfx,
|
|
2132
|
+
action: () => bulkSetFlagged(rowTargets(), !isFlagged),
|
|
1889
2133
|
},
|
|
1890
2134
|
{ label: "", action: () => { }, separator: true },
|
|
1891
2135
|
// Drafts get an explicit "Edit draft" entry as the primary action —
|