@bobfrankston/rmfmail 1.2.50 → 1.2.66
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 +11 -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 +9 -9
- 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
|
@@ -58,6 +58,17 @@ function applyLiveFilter(body: HTMLElement): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
let currentSearchQuery = "";
|
|
61
|
+
|
|
62
|
+
/** Empty-list placeholder text. When a search is active, name the query so an
|
|
63
|
+
* empty list reads as "nothing matched THIS search" rather than a bare,
|
|
64
|
+
* alarming "No messages" (Bob 2026-06-25). Covers paths other than the
|
|
65
|
+
* search loader itself — e.g. deleting the last surviving search hit. */
|
|
66
|
+
function emptyListMessage(): string {
|
|
67
|
+
if (searchMode && currentSearchQuery) {
|
|
68
|
+
return `No messages found by "${escapeHtmlText(currentSearchQuery)}"`;
|
|
69
|
+
}
|
|
70
|
+
return "No messages";
|
|
71
|
+
}
|
|
61
72
|
// Remember the pre-search view mode so clearSearchMode() can restore it.
|
|
62
73
|
// Without this, searching from "All Inboxes" loses the unified-mode flag
|
|
63
74
|
// (loadSearchResults sets unifiedMode=false), and clearing the search
|
|
@@ -404,6 +415,156 @@ export function setRowSeen(accountId: string, uid: number, yes: boolean): void {
|
|
|
404
415
|
if (row) row.setUnreadClass(!yes);
|
|
405
416
|
}
|
|
406
417
|
|
|
418
|
+
/** Resolve currently-selected rows to MessageRow objects (skips any not in
|
|
419
|
+
* the loaded DOM). */
|
|
420
|
+
function selectedRows(): MessageRow[] {
|
|
421
|
+
const out: MessageRow[] = [];
|
|
422
|
+
for (const s of getSelectedMessages()) {
|
|
423
|
+
const row = rowByKey.get(rowKey(s.accountId, s.uid));
|
|
424
|
+
if (row) out.push(row);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Every row currently shown (visible = matches search + view filters, and
|
|
430
|
+
* loaded — the list lazy-loads). */
|
|
431
|
+
function visibleRowObjs(): MessageRow[] {
|
|
432
|
+
const body = document.getElementById("ml-body");
|
|
433
|
+
if (!body) return [];
|
|
434
|
+
return Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
|
|
435
|
+
.map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
|
|
436
|
+
.filter((r): r is MessageRow => !!r);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The set a bulk action targets: the SELECTED rows if any are marked,
|
|
440
|
+
* otherwise ALL shown rows. This is the "act on selected, else all filtered"
|
|
441
|
+
* rule Bob asked for (2026-06-24). */
|
|
442
|
+
function bulkTargets(): MessageRow[] {
|
|
443
|
+
const sel = selectedRows();
|
|
444
|
+
return sel.length > 0 ? sel : visibleRowObjs();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Flag / unflag a set of rows. Captures the prior flagged state per row and
|
|
448
|
+
* emits `mailx-flagged-batch` so app.ts can register ONE Ctrl+Z undo entry
|
|
449
|
+
* for the whole batch (Bob 2026-06-24: bulk ops need undo for safety). */
|
|
450
|
+
async function bulkSetFlagged(rows: MessageRow[], flagged: boolean): Promise<void> {
|
|
451
|
+
const undo: { accountId: string; uid: number; prevFlagged: boolean }[] = [];
|
|
452
|
+
await Promise.all(rows.map(async (row) => {
|
|
453
|
+
// Read current state from the VISIBLE class via row.isFlagged — NOT
|
|
454
|
+
// flaggedOf(row.msg). The two can drift, and a stale `msg.flags` made
|
|
455
|
+
// Unflag skip flagged rows as "already unflagged" (Bob 2026-06-24
|
|
456
|
+
// "unflag is not removing the flag"). The class is the source of truth
|
|
457
|
+
// the single-star toggle already trusts.
|
|
458
|
+
const prev = row.isFlagged;
|
|
459
|
+
if (prev === flagged) return; // already in the target state
|
|
460
|
+
const prevFlags = [...(row.msg.flags || [])];
|
|
461
|
+
row.isFlagged = flagged; // setter: updates class + msg.flags
|
|
462
|
+
try {
|
|
463
|
+
await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
464
|
+
state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
465
|
+
undo.push({ accountId: row.accountId, uid: row.msg.uid, prevFlagged: prev });
|
|
466
|
+
} catch {
|
|
467
|
+
row.msg.flags = prevFlags; // revert on server failure
|
|
468
|
+
row.setFlaggedClass(prev);
|
|
469
|
+
}
|
|
470
|
+
}));
|
|
471
|
+
if (undo.length) document.dispatchEvent(new CustomEvent("mailx-flagged-batch", { detail: undo }));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Mark a set of rows read / unread. Low-risk + trivially reversible, so no
|
|
475
|
+
* undo entry (unlike flag, move, delete). State read from the class too. */
|
|
476
|
+
async function bulkSetSeen(rows: MessageRow[], seen: boolean): Promise<void> {
|
|
477
|
+
await Promise.all(rows.map(async (row) => {
|
|
478
|
+
const prev = row.isSeen;
|
|
479
|
+
if (prev === seen) return;
|
|
480
|
+
const prevFlags = [...(row.msg.flags || [])];
|
|
481
|
+
row.isSeen = seen; // setter: updates class + msg.flags
|
|
482
|
+
try {
|
|
483
|
+
await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
484
|
+
state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
|
|
485
|
+
} catch {
|
|
486
|
+
row.msg.flags = prevFlags;
|
|
487
|
+
row.setUnreadClass(!prev);
|
|
488
|
+
}
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Move every selected message (of the first selected row's account) to a
|
|
493
|
+
* picked folder. Local-first: optimistic remove + fire-and-forget IPC, same
|
|
494
|
+
* as the row context menu's "Move to folder…". */
|
|
495
|
+
async function bulkMoveSelection(): Promise<void> {
|
|
496
|
+
const targets = bulkTargets();
|
|
497
|
+
if (targets.length === 0) return;
|
|
498
|
+
const accountId = targets[0].accountId;
|
|
499
|
+
const same = targets.filter(r => r.accountId === accountId);
|
|
500
|
+
const pick = await pickFolder(accountId, { excludeFolderIds: [same[0].msg.folderId] });
|
|
501
|
+
if (!pick) return;
|
|
502
|
+
const uids = same.map(r => r.msg.uid);
|
|
503
|
+
removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
|
|
504
|
+
const statusSync = document.getElementById("status-sync");
|
|
505
|
+
if (statusSync) statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? "s" : ""} to ${pick.folderName}…`;
|
|
506
|
+
apiMoveMessages(accountId, uids, pick.folderId)
|
|
507
|
+
.then(() => { if (statusSync) statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })
|
|
508
|
+
.catch((err: any) => { if (statusSync) statusSync.textContent = `Move sync issue: ${err?.message || err}`; });
|
|
509
|
+
exitMultiSelect();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Toolbar "Filtered" menu — home for bulk operations. Two concepts, kept
|
|
513
|
+
* separate (Bob 2026-06-24): SHOWN = rows the current folder/search/filters
|
|
514
|
+
* make visible (lazy-loaded, so it's what's loaded now); SELECTED = rows
|
|
515
|
+
* you've clicked. Actions auto-target: if anything is SELECTED they act on
|
|
516
|
+
* that, otherwise on ALL SHOWN — the header says which is in effect. Delete
|
|
517
|
+
* and Mark-as-spam are omitted: the toolbar's own buttons already do those on
|
|
518
|
+
* the selection. Flag/unflag and move are Ctrl+Z-undoable. */
|
|
519
|
+
export async function showSelectionMenu(x: number, y: number): Promise<void> {
|
|
520
|
+
const { showContextMenu } = await import("./context-menu.js");
|
|
521
|
+
const body = document.getElementById("ml-body");
|
|
522
|
+
const visibleRows = body
|
|
523
|
+
? Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
|
|
524
|
+
: [];
|
|
525
|
+
const enterMulti = () => body?.classList.add("multi-select-on");
|
|
526
|
+
const shownCount = visibleRows.length;
|
|
527
|
+
const selCount = getSelectedMessages().length;
|
|
528
|
+
const hasShown = shownCount > 0;
|
|
529
|
+
// Auto-target: selected if any, else all shown. The action labels carry
|
|
530
|
+
// the effective target count so there's no ambiguity about what runs.
|
|
531
|
+
const targetN = selCount > 0 ? selCount : shownCount;
|
|
532
|
+
const onWhat = selCount > 0 ? `selected (${selCount})` : `all shown (${shownCount})`;
|
|
533
|
+
const canAct = targetN > 0;
|
|
534
|
+
|
|
535
|
+
const items: MenuItem[] = [
|
|
536
|
+
{ label: `Acting on ${onWhat}`, action: () => {}, disabled: true },
|
|
537
|
+
{ label: "", action: () => {}, separator: true },
|
|
538
|
+
{
|
|
539
|
+
label: `Select all shown (${shownCount})`,
|
|
540
|
+
action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.add("selected"); lastClickedRow = visibleRows[visibleRows.length - 1] || null; updateBulkBar(); },
|
|
541
|
+
disabled: !hasShown,
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
label: "Select flagged ★ among shown",
|
|
545
|
+
action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.toggle("selected", r.classList.contains("flagged")); lastClickedRow = null; updateBulkBar(); },
|
|
546
|
+
disabled: !hasShown,
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
label: "Invert selection among shown",
|
|
550
|
+
action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.toggle("selected"); updateBulkBar(); },
|
|
551
|
+
disabled: !hasShown,
|
|
552
|
+
},
|
|
553
|
+
{ label: `Deselect all (${selCount})`, action: () => exitMultiSelect(), disabled: selCount === 0 },
|
|
554
|
+
{ label: "", action: () => {}, separator: true },
|
|
555
|
+
// Actions — auto-targeted (selected else all shown). No count on the
|
|
556
|
+
// verb itself (a number on "Flag" reads as ambiguous, Bob 2026-06-24);
|
|
557
|
+
// the "Acting on …" header above carries the target count.
|
|
558
|
+
{ label: "Flag", action: () => bulkSetFlagged(bulkTargets(), true), disabled: !canAct },
|
|
559
|
+
{ label: "Unflag", action: () => bulkSetFlagged(bulkTargets(), false), disabled: !canAct },
|
|
560
|
+
{ label: "Mark read", action: () => bulkSetSeen(bulkTargets(), true), disabled: !canAct },
|
|
561
|
+
{ label: "Mark unread", action: () => bulkSetSeen(bulkTargets(), false), disabled: !canAct },
|
|
562
|
+
{ label: "", action: () => {}, separator: true },
|
|
563
|
+
{ label: "Move to folder…", action: () => bulkMoveSelection(), disabled: !canAct },
|
|
564
|
+
];
|
|
565
|
+
showContextMenu(x, y, items);
|
|
566
|
+
}
|
|
567
|
+
|
|
407
568
|
/** Scroll the focused row into view. Wired to a keyboard shortcut so the
|
|
408
569
|
* user can recover after scrolling the list away from the preview.
|
|
409
570
|
*
|
|
@@ -832,7 +993,7 @@ export function removeMessagesAndReconcile(
|
|
|
832
993
|
}
|
|
833
994
|
}
|
|
834
995
|
if (state.getMessages().length === 0) {
|
|
835
|
-
body.innerHTML = `<div class="ml-empty"
|
|
996
|
+
body.innerHTML = `<div class="ml-empty">${emptyListMessage()}</div>`;
|
|
836
997
|
}
|
|
837
998
|
}
|
|
838
999
|
|
|
@@ -926,10 +1087,14 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
926
1087
|
if (cached) {
|
|
927
1088
|
totalMessages = cached.total;
|
|
928
1089
|
state.setMessages(cached.items);
|
|
929
|
-
|
|
1090
|
+
// See loadMessages: anchor the cache render on background reloads so a
|
|
1091
|
+
// scrolled reader isn't yanked to the top when new mail arrives.
|
|
1092
|
+
const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
|
|
1093
|
+
if (!autoSelect) withScrollAnchor(body, renderCached);
|
|
1094
|
+
else renderCached();
|
|
930
1095
|
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
931
1096
|
if (targetUuid) {
|
|
932
|
-
body.scrollTop = savedScroll;
|
|
1097
|
+
if (autoSelect) body.scrollTop = savedScroll;
|
|
933
1098
|
restoreSelection(body, targetUuid);
|
|
934
1099
|
} else if (autoSelect) {
|
|
935
1100
|
selectFirst(body);
|
|
@@ -1043,7 +1208,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
1043
1208
|
);
|
|
1044
1209
|
totalMessages = matches.length;
|
|
1045
1210
|
state.setMessages(matches);
|
|
1046
|
-
if (matches.length === 0) { body.innerHTML = `<div class="ml-empty">No
|
|
1211
|
+
if (matches.length === 0) { body.innerHTML = `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"</div>`; return; }
|
|
1047
1212
|
body.innerHTML = "";
|
|
1048
1213
|
appendMessages(body, "", matches);
|
|
1049
1214
|
selectFirst(body);
|
|
@@ -1069,7 +1234,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
1069
1234
|
if (result.items.length === 0) {
|
|
1070
1235
|
state.setMessages([]);
|
|
1071
1236
|
body.innerHTML = partialNote +
|
|
1072
|
-
`<div class="ml-empty">No
|
|
1237
|
+
`<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"${partial ? " in the folders that responded" : ""}</div>`;
|
|
1073
1238
|
return;
|
|
1074
1239
|
}
|
|
1075
1240
|
|
|
@@ -1084,7 +1249,16 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
1084
1249
|
}
|
|
1085
1250
|
}
|
|
1086
1251
|
|
|
1087
|
-
|
|
1252
|
+
/** Search-as-find: leave search and reveal a specific message in its own
|
|
1253
|
+
* folder. Loads the folder list, selects the message, and scrolls it into
|
|
1254
|
+
* view. Used when the user closes search while a result is selected — they
|
|
1255
|
+
* "stay at" that message in its real folder instead of bouncing back to the
|
|
1256
|
+
* pre-search view (Bob 2026-06-25). */
|
|
1257
|
+
export function revealMessage(accountId: string, folderId: number, uid: number, specialUse = ""): void {
|
|
1258
|
+
loadMessages(accountId, folderId, 1, specialUse, true, uid);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = "", autoSelect = true, focusUid?: number): Promise<void> {
|
|
1088
1262
|
const myGen = ++loadGen;
|
|
1089
1263
|
searchMode = false;
|
|
1090
1264
|
unifiedMode = false;
|
|
@@ -1127,11 +1301,26 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1127
1301
|
if (cached) {
|
|
1128
1302
|
totalMessages = cached.total;
|
|
1129
1303
|
state.setMessages(cached.items);
|
|
1130
|
-
|
|
1304
|
+
// Background reload (new mail / sync): a bare renderMessages rebuilds
|
|
1305
|
+
// innerHTML and resets scrollTop to 0, which then makes the follow-up
|
|
1306
|
+
// network anchor think the user is at the top and reveal new mail there
|
|
1307
|
+
// — yanking a scrolled-down reader to the top (Bob 2026-06-24 "the list
|
|
1308
|
+
// jumps when a new message is added ... must not move while I'm
|
|
1309
|
+
// viewing it"). Anchor the cache render too so the visible rows stay put.
|
|
1310
|
+
const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
|
|
1311
|
+
if (!autoSelect) withScrollAnchor(body, renderCached);
|
|
1312
|
+
else renderCached();
|
|
1131
1313
|
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
1132
|
-
if (
|
|
1314
|
+
if (focusUid != null) {
|
|
1315
|
+
// Search-as-find: jump to the specific message the user selected.
|
|
1316
|
+
requestAnimationFrame(() => {
|
|
1317
|
+
if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect) selectFirst(body);
|
|
1318
|
+
});
|
|
1319
|
+
} else if (targetUuid) {
|
|
1133
1320
|
requestAnimationFrame(() => {
|
|
1134
|
-
|
|
1321
|
+
// Folder switch restores saved pixels; a background reload was
|
|
1322
|
+
// already placed by the anchor — don't override it.
|
|
1323
|
+
if (autoSelect) body.scrollTop = savedScroll;
|
|
1135
1324
|
restoreSelection(body, targetUuid);
|
|
1136
1325
|
});
|
|
1137
1326
|
} else if (autoSelect) {
|
|
@@ -1184,7 +1373,13 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
1184
1373
|
|
|
1185
1374
|
// Prefer saved position; otherwise default by autoSelect.
|
|
1186
1375
|
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
1187
|
-
if (
|
|
1376
|
+
if (focusUid != null) {
|
|
1377
|
+
// Search-as-find: select the message the user picked from results.
|
|
1378
|
+
requestAnimationFrame(() => {
|
|
1379
|
+
if (myGen !== loadGen) return;
|
|
1380
|
+
if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect) selectFirst(body);
|
|
1381
|
+
});
|
|
1382
|
+
} else if (targetUuid) {
|
|
1188
1383
|
requestAnimationFrame(() => {
|
|
1189
1384
|
if (myGen !== loadGen) return;
|
|
1190
1385
|
// On a first paint (no cache), use the saved scrollTop.
|
|
@@ -1211,6 +1406,15 @@ async function loadMoreMessages(): Promise<void> {
|
|
|
1211
1406
|
loading = true;
|
|
1212
1407
|
currentPage++;
|
|
1213
1408
|
|
|
1409
|
+
// Diagnostics for the "scroll near the bottom does nothing" report
|
|
1410
|
+
// (Bob 2026-06-22: position-dependent, "go up a tad and it works"). The
|
|
1411
|
+
// suspect is a loadMore that doesn't grow scrollHeight — duplicate/empty
|
|
1412
|
+
// page, end-of-list overshoot — leaving the user pinned. Capture
|
|
1413
|
+
// before/after geometry + how many rows the page actually added so the
|
|
1414
|
+
// log shows whether the list grew, stalled, or the page came back empty.
|
|
1415
|
+
const t0 = Date.now();
|
|
1416
|
+
const heightBefore = body.scrollHeight;
|
|
1417
|
+
const rowsBefore = body.querySelectorAll(".ml-row").length;
|
|
1214
1418
|
try {
|
|
1215
1419
|
const flaggedOnly = body.classList.contains("flagged-only");
|
|
1216
1420
|
const result = searchMode
|
|
@@ -1222,6 +1426,11 @@ async function loadMoreMessages(): Promise<void> {
|
|
|
1222
1426
|
const current = state.getMessages();
|
|
1223
1427
|
state.setMessages([...current, ...result.items]);
|
|
1224
1428
|
appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
|
|
1429
|
+
const rowsAfter = body.querySelectorAll(".ml-row").length;
|
|
1430
|
+
const heightAfter = body.scrollHeight;
|
|
1431
|
+
const elapsed = Date.now() - t0;
|
|
1432
|
+
const grew = heightAfter > heightBefore;
|
|
1433
|
+
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"}`);
|
|
1225
1434
|
} catch (e: any) {
|
|
1226
1435
|
console.error(`Load more error: ${e.message}`);
|
|
1227
1436
|
} finally {
|
|
@@ -1231,11 +1440,29 @@ async function loadMoreMessages(): Promise<void> {
|
|
|
1231
1440
|
|
|
1232
1441
|
/** Replace body contents with rendered rows */
|
|
1233
1442
|
function renderMessages(body: HTMLElement, accountId: string, items: any[], opts: { background?: boolean } = {}): void {
|
|
1443
|
+
// MULTI-SELECT SURVIVAL: replaceChildren below builds fresh rows with no
|
|
1444
|
+
// `.selected`, so a sync/new-mail rebuild would collapse an active
|
|
1445
|
+
// multi-selection down to the single focused row restoreSelection re-binds
|
|
1446
|
+
// (Bob 2026-06-22: "selected multiple NMLS spam, suddenly seeing only one —
|
|
1447
|
+
// a new message arrived and confused you"). The `multi-select-on` class
|
|
1448
|
+
// lives on the body and survives the rebuild, so when it's set we snapshot
|
|
1449
|
+
// the selected rows' stable uuids and re-apply `.selected` to whichever
|
|
1450
|
+
// survive the new render. Keyed on uuid, so a folder switch (different
|
|
1451
|
+
// uuids) matches nothing and correctly drops the stale highlight.
|
|
1452
|
+
const preserveSel = body.classList.contains("multi-select-on")
|
|
1453
|
+
? new Set(Array.from(body.querySelectorAll<HTMLElement>(".ml-row.selected"))
|
|
1454
|
+
.map(r => r.dataset.uuid || "").filter(Boolean))
|
|
1455
|
+
: null;
|
|
1234
1456
|
const fragment = document.createDocumentFragment();
|
|
1235
1457
|
const tempDiv = document.createElement("div");
|
|
1236
1458
|
appendMessages(tempDiv, accountId, items);
|
|
1237
1459
|
while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
|
|
1238
1460
|
body.replaceChildren(fragment);
|
|
1461
|
+
if (preserveSel && preserveSel.size > 0) {
|
|
1462
|
+
for (const uuid of preserveSel) {
|
|
1463
|
+
body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1239
1466
|
// A filter (flagged-only / priority-only) that hides EVERY row makes the
|
|
1240
1467
|
// list look broken — identical to an empty/failed load. Bob 2026-05-27
|
|
1241
1468
|
// lost an afternoon to this: the flagged-only filter was stuck on (it
|
|
@@ -1671,6 +1898,19 @@ class MessageRow {
|
|
|
1671
1898
|
|
|
1672
1899
|
private onFlagClick(e: MouseEvent): void {
|
|
1673
1900
|
e.stopPropagation();
|
|
1901
|
+
// Multi-select: clicking the ★ on one of several highlighted rows acts
|
|
1902
|
+
// on the WHOLE selection, not just this row — same as the context-menu
|
|
1903
|
+
// Flag/Unflag (Bob 2026-06-26: "clear the * only clears one not the
|
|
1904
|
+
// selection"). Target state is the toggle of the clicked row, so a
|
|
1905
|
+
// flagged row clears the lot and an unflagged row flags the lot.
|
|
1906
|
+
const body = this.el.parentElement as HTMLElement | null;
|
|
1907
|
+
if (body?.classList.contains("multi-select-on") && this.el.classList.contains("selected")) {
|
|
1908
|
+
const sel = selectedRows();
|
|
1909
|
+
if (sel.length > 1) {
|
|
1910
|
+
void bulkSetFlagged(sel, !this.el.classList.contains("flagged"));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1674
1914
|
// Local-first: flip the visual class + in-memory flag array NOW so the
|
|
1675
1915
|
// star toggles under the cursor with zero latency, then mirror to the
|
|
1676
1916
|
// server in the background. The earlier `await updateFlags(...)`
|
|
@@ -1845,36 +2085,24 @@ class MessageRow {
|
|
|
1845
2085
|
const msg = this.msg;
|
|
1846
2086
|
const self = this;
|
|
1847
2087
|
|
|
2088
|
+
// Count of selected rows — when >1, the read/flag actions apply to the
|
|
2089
|
+
// whole selection and the label says so (e.g. "Unflag (5)"). The row
|
|
2090
|
+
// menu's fallback (nothing selected) is the clicked row, NOT all shown.
|
|
2091
|
+
const selCount = getSelectedMessages().length;
|
|
2092
|
+
const countSfx = selCount > 1 ? ` (${selCount})` : "";
|
|
2093
|
+
const rowTargets = (): MessageRow[] => { const s = selectedRows(); return s.length > 0 ? s : [self]; };
|
|
2094
|
+
|
|
1848
2095
|
const items: MenuItem[] = [
|
|
1849
2096
|
{
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
// debounce; the new flag array is computed via the
|
|
1856
|
-
// typed setter and sent to the server.
|
|
1857
|
-
const probe = { flags: [...(msg.flags || [])] };
|
|
1858
|
-
setSeen(probe, !isSeen);
|
|
1859
|
-
try {
|
|
1860
|
-
await updateFlags(accountId, msg.uid, probe.flags);
|
|
1861
|
-
msg.flags = probe.flags;
|
|
1862
|
-
state.updateMessageFlags(accountId, msg.uid, probe.flags);
|
|
1863
|
-
self.setUnreadClass(isSeen);
|
|
1864
|
-
} catch { /* ignore */ }
|
|
1865
|
-
},
|
|
2097
|
+
// Target state = inverse of what the menu showed, matching what
|
|
2098
|
+
// the user clicked. Visual-state-based labels stay coherent with
|
|
2099
|
+
// the auto-mark-as-read debounce. Applies to the whole selection.
|
|
2100
|
+
label: (isSeen ? "Mark unread" : "Mark read") + countSfx,
|
|
2101
|
+
action: () => bulkSetSeen(rowTargets(), !isSeen),
|
|
1866
2102
|
},
|
|
1867
2103
|
{
|
|
1868
|
-
label: isFlagged ? "Unflag" : "Flag",
|
|
1869
|
-
action:
|
|
1870
|
-
const probe = { flags: [...(msg.flags || [])] };
|
|
1871
|
-
setFlagged(probe, !isFlagged);
|
|
1872
|
-
try {
|
|
1873
|
-
await updateFlags(accountId, msg.uid, probe.flags);
|
|
1874
|
-
msg.flags = probe.flags;
|
|
1875
|
-
self.setFlaggedClass(!isFlagged);
|
|
1876
|
-
} catch { /* ignore */ }
|
|
1877
|
-
},
|
|
2104
|
+
label: (isFlagged ? "Unflag" : "Flag") + countSfx,
|
|
2105
|
+
action: () => bulkSetFlagged(rowTargets(), !isFlagged),
|
|
1878
2106
|
},
|
|
1879
2107
|
{ label: "", action: () => {}, separator: true },
|
|
1880
2108
|
// Drafts get an explicit "Edit draft" entry as the primary action —
|
|
@@ -1367,8 +1367,8 @@ function markFolderRead(accountId, folderId) {
|
|
|
1367
1367
|
function createFolder(accountId, parentPath, name) {
|
|
1368
1368
|
return ipc().createFolder?.(accountId, parentPath, name);
|
|
1369
1369
|
}
|
|
1370
|
-
function renameFolder(accountId, folderId, newName) {
|
|
1371
|
-
return ipc().renameFolder?.(accountId, folderId, newName);
|
|
1370
|
+
function renameFolder(accountId, folderId, newName, newParentId) {
|
|
1371
|
+
return ipc().renameFolder?.(accountId, folderId, newName, newParentId);
|
|
1372
1372
|
}
|
|
1373
1373
|
function deleteFolder(accountId, folderId) {
|
|
1374
1374
|
return ipc().deleteFolder?.(accountId, folderId);
|
|
@@ -2301,20 +2301,35 @@ async function createTinyMceEditor(container2, opts = {}) {
|
|
|
2301
2301
|
}
|
|
2302
2302
|
}
|
|
2303
2303
|
});
|
|
2304
|
+
const QUOTE_SELECTOR = "blockquote, div.reply, .gmail_quote, .moz-cite-prefix, pre.moz-quote-pre, [type=cite]";
|
|
2305
|
+
const markQuotedNoSpellcheck = (body) => {
|
|
2306
|
+
if (!body?.querySelectorAll)
|
|
2307
|
+
return;
|
|
2308
|
+
body.querySelectorAll(QUOTE_SELECTOR).forEach((el) => {
|
|
2309
|
+
el.setAttribute("spellcheck", "false");
|
|
2310
|
+
});
|
|
2311
|
+
};
|
|
2312
|
+
ed.on("SetContent", () => {
|
|
2313
|
+
try {
|
|
2314
|
+
markQuotedNoSpellcheck(ed.getBody());
|
|
2315
|
+
} catch {
|
|
2316
|
+
}
|
|
2317
|
+
});
|
|
2304
2318
|
ed.on("init", () => {
|
|
2305
2319
|
try {
|
|
2306
2320
|
const body = ed.getBody();
|
|
2307
2321
|
if (body) {
|
|
2308
2322
|
body.setAttribute("spellcheck", "true");
|
|
2309
2323
|
body.setAttribute("lang", "en");
|
|
2324
|
+
markQuotedNoSpellcheck(body);
|
|
2310
2325
|
}
|
|
2311
2326
|
const doc = ed.getDoc();
|
|
2312
2327
|
if (doc?.documentElement)
|
|
2313
2328
|
doc.documentElement.setAttribute("lang", "en");
|
|
2314
2329
|
} catch {
|
|
2315
2330
|
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2331
|
+
const reflowRaf = (ed.getWin && ed.getWin() || window).requestAnimationFrame || ((f) => setTimeout(f, 16));
|
|
2332
|
+
reflowRaf(() => applyZoom());
|
|
2318
2333
|
try {
|
|
2319
2334
|
const doc = ed.getDoc();
|
|
2320
2335
|
doc.addEventListener("wheel", (e) => {
|
|
@@ -3345,12 +3360,22 @@ function createQuillEditor(container2) {
|
|
|
3345
3360
|
};
|
|
3346
3361
|
}
|
|
3347
3362
|
async function createTiptapEditor(container2) {
|
|
3348
|
-
const
|
|
3349
|
-
const
|
|
3350
|
-
const
|
|
3351
|
-
const
|
|
3352
|
-
const
|
|
3353
|
-
const
|
|
3363
|
+
const w = window;
|
|
3364
|
+
const core = w["@tiptap/core"];
|
|
3365
|
+
const starter = w["@tiptap/starter-kit"];
|
|
3366
|
+
const link = w["@tiptap/extension-link"];
|
|
3367
|
+
const image = w["@tiptap/extension-image"];
|
|
3368
|
+
const underline = w["@tiptap/extension-underline"];
|
|
3369
|
+
const placeholder = w["@tiptap/extension-placeholder"];
|
|
3370
|
+
const Editor = core?.Editor;
|
|
3371
|
+
const StarterKit = starter?.StarterKit ?? starter?.default;
|
|
3372
|
+
const Link = link?.Link ?? link?.default;
|
|
3373
|
+
const Image = image?.Image ?? image?.default;
|
|
3374
|
+
const Underline = underline?.Underline ?? underline?.default;
|
|
3375
|
+
const Placeholder = placeholder?.Placeholder ?? placeholder?.default;
|
|
3376
|
+
if (!Editor || !StarterKit) {
|
|
3377
|
+
throw new Error("tiptap UMD globals not found (expected window['@tiptap/core'].Editor)");
|
|
3378
|
+
}
|
|
3354
3379
|
const toolbar = document.createElement("div");
|
|
3355
3380
|
toolbar.className = "tt-toolbar";
|
|
3356
3381
|
toolbar.innerHTML = `
|
|
@@ -3695,8 +3720,11 @@ function setActiveEditorBadge(type) {
|
|
|
3695
3720
|
tinymce: "https://www.tiny.cloud/docs/tinymce/6/",
|
|
3696
3721
|
fallback: "https://github.com/BobFrankston/mailx/blob/master/app/docs/editor.md"
|
|
3697
3722
|
};
|
|
3698
|
-
|
|
3723
|
+
const requested = editorType;
|
|
3724
|
+
const fellBack = type !== "fallback" && type !== requested;
|
|
3725
|
+
el.textContent = fellBack ? `${labels[type] || type} (${labels[requested] || requested} unavailable)` : labels[type] || type;
|
|
3699
3726
|
el.dataset.editor = type;
|
|
3727
|
+
el.dataset.fellBack = fellBack ? "true" : "false";
|
|
3700
3728
|
const url = docs[type];
|
|
3701
3729
|
if (url) {
|
|
3702
3730
|
el.style.cursor = "pointer";
|
|
@@ -3824,6 +3852,18 @@ setActiveEditorBadge(activeEditorType);
|
|
|
3824
3852
|
})();
|
|
3825
3853
|
var fromInput = document.getElementById("compose-from-input");
|
|
3826
3854
|
var fromOptions = document.getElementById("compose-from-options");
|
|
3855
|
+
if (fromInput) {
|
|
3856
|
+
fromInput.addEventListener("focus", () => {
|
|
3857
|
+
fromInput.dataset.prevFrom = fromInput.value;
|
|
3858
|
+
fromInput.value = "";
|
|
3859
|
+
});
|
|
3860
|
+
fromInput.addEventListener("blur", () => {
|
|
3861
|
+
if (fromInput.value.trim() === "" && fromInput.dataset.prevFrom) {
|
|
3862
|
+
fromInput.value = fromInput.dataset.prevFrom;
|
|
3863
|
+
}
|
|
3864
|
+
delete fromInput.dataset.prevFrom;
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3827
3867
|
var toInput = document.getElementById("compose-to");
|
|
3828
3868
|
var ccInput = document.getElementById("compose-cc");
|
|
3829
3869
|
var bccInput = document.getElementById("compose-bcc");
|
|
@@ -4248,18 +4288,24 @@ setupAutocomplete(toInput);
|
|
|
4248
4288
|
setupAutocomplete(ccInput);
|
|
4249
4289
|
setupAutocomplete(bccInput);
|
|
4250
4290
|
function splitRecipients(s) {
|
|
4251
|
-
const
|
|
4291
|
+
const raw = [];
|
|
4252
4292
|
let cur = "", inQuote = false;
|
|
4253
4293
|
for (const c of s) {
|
|
4254
4294
|
if (c === '"') {
|
|
4255
4295
|
inQuote = !inQuote;
|
|
4256
4296
|
cur += c;
|
|
4257
4297
|
} else if (c === "," && !inQuote) {
|
|
4258
|
-
|
|
4298
|
+
raw.push(cur);
|
|
4259
4299
|
cur = "";
|
|
4260
4300
|
} else cur += c;
|
|
4261
4301
|
}
|
|
4262
|
-
|
|
4302
|
+
raw.push(cur);
|
|
4303
|
+
const out = [];
|
|
4304
|
+
for (let i = 0; i < raw.length; i++) {
|
|
4305
|
+
let seg = raw[i];
|
|
4306
|
+
while (i < raw.length - 1 && !/[@<]/.test(seg)) seg += "," + raw[++i];
|
|
4307
|
+
out.push(seg);
|
|
4308
|
+
}
|
|
4263
4309
|
return out;
|
|
4264
4310
|
}
|
|
4265
4311
|
function tokenSpanAtCaret(s, caret) {
|
|
@@ -4423,6 +4469,16 @@ function applyInit(init) {
|
|
|
4423
4469
|
}
|
|
4424
4470
|
replyInReplyTo = init.inReplyTo || "";
|
|
4425
4471
|
replyReferences = init.references || [];
|
|
4472
|
+
if (Array.isArray(init.attachments) && init.attachments.length && attachments.length === 0) {
|
|
4473
|
+
for (const a of init.attachments) {
|
|
4474
|
+
if (a && a.dataBase64) {
|
|
4475
|
+
const b64 = a.dataBase64;
|
|
4476
|
+
const size = Math.max(0, Math.floor(b64.length * 3 / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0));
|
|
4477
|
+
attachments.push({ filename: a.filename, mimeType: a.mimeType, size, dataBase64: b64 });
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
renderAttachmentChips();
|
|
4481
|
+
}
|
|
4426
4482
|
setComposeTitle(init.subject || "");
|
|
4427
4483
|
if (!toInput.value) toInput.focus();
|
|
4428
4484
|
else if (!subjectInput.value) subjectInput.focus();
|