@bobfrankston/rmfmail 1.1.131 → 1.1.132
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/.commitmsg +18 -6
- package/client/app.bundle.js +120 -57
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +150 -69
- package/client/app.js.map +1 -1
- package/client/app.ts +151 -60
- package/client/components/message-list.js +15 -0
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +16 -0
- package/client/index.html +2 -0
- package/client/styles/components.css +5 -1
- package/npmchanges.md +10 -0
- package/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-59116 → node_modules.npmglobalize-stash-50740}/.package-lock.json +0 -0
package/.commitmsg
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
feat: multi-level undo, viewer fate-sharing, ★ filter + ★ bulk-flag
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.
|
|
3
|
+
- Multi-level undo (Ctrl+Z). Replaces single-slot lastDeleted / lastMoved
|
|
4
|
+
with a LIFO undoStack. Each user-driven destructive action (delete,
|
|
5
|
+
move, bulk-flag) pushes a typed UndoOp; Ctrl+Z pops and reverses via
|
|
6
|
+
existing service IPC. Bounded at 50 entries, 10 min per-entry TTL,
|
|
7
|
+
checked at pop time. No redo (undoing an undo isn't itself undoable).
|
|
8
|
+
- Viewer/list fate-sharing: when loadMessages produces an empty result OR
|
|
9
|
+
the focused row's message isn't in the freshly-loaded set (filter
|
|
10
|
+
toggle, sync drop, navigation), clearViewer() runs so the preview pane
|
|
11
|
+
doesn't keep showing a stale message that the list says doesn't exist.
|
|
12
|
+
- Search-bar ★ filter button (id=btn-filter-flagged). Mirrors the
|
|
13
|
+
buried-in-View-menu opt-flagged checkbox so the flagged-only filter is
|
|
14
|
+
reachable in one click. Pressed/active state via .active CSS marker.
|
|
15
|
+
- Toolbar ★ bulk-flag button (id=btn-tb-flag) next to trash/spam.
|
|
16
|
+
Operates on the message-list selection (falls back to viewer focus when
|
|
17
|
+
empty). Smart toggle: if any selected row is unflagged → flag all;
|
|
18
|
+
if all flagged → unflag all. Pushes a single undo entry capturing each
|
|
19
|
+
row's prior state so Ctrl+Z restores the exact mix.
|
package/client/app.bundle.js
CHANGED
|
@@ -3057,10 +3057,19 @@ async function loadMessages(accountId, folderId, page = 1, specialUse = "", auto
|
|
|
3057
3057
|
if (result.items.length === 0) {
|
|
3058
3058
|
setMessages([]);
|
|
3059
3059
|
body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
|
|
3060
|
+
clearViewer();
|
|
3061
|
+
focusedRow = null;
|
|
3060
3062
|
return;
|
|
3061
3063
|
}
|
|
3062
3064
|
setMessages(result.items);
|
|
3063
3065
|
renderMessages(body, accountId, result.items);
|
|
3066
|
+
if (focusedRow) {
|
|
3067
|
+
const stillThere = result.items.some((m) => m.accountId === focusedRow.accountId && m.uid === focusedRow.msg.uid);
|
|
3068
|
+
if (!stillThere) {
|
|
3069
|
+
clearViewer();
|
|
3070
|
+
focusedRow = null;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3064
3073
|
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
3065
3074
|
if (targetUuid) {
|
|
3066
3075
|
requestAnimationFrame(() => {
|
|
@@ -7487,9 +7496,21 @@ function forwardBody(msg) {
|
|
|
7487
7496
|
const body = sanitizeQuotedBody(msg);
|
|
7488
7497
|
return `<p></p><br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
|
|
7489
7498
|
}
|
|
7490
|
-
var
|
|
7491
|
-
var
|
|
7492
|
-
var
|
|
7499
|
+
var undoStack = [];
|
|
7500
|
+
var UNDO_MAX = 50;
|
|
7501
|
+
var UNDO_TTL_MS = 10 * 6e4;
|
|
7502
|
+
function pushUndo(op) {
|
|
7503
|
+
undoStack.push(op);
|
|
7504
|
+
if (undoStack.length > UNDO_MAX) undoStack.shift();
|
|
7505
|
+
}
|
|
7506
|
+
function popUndo() {
|
|
7507
|
+
const now = Date.now();
|
|
7508
|
+
while (undoStack.length > 0) {
|
|
7509
|
+
const op = undoStack.pop();
|
|
7510
|
+
if (now - op.at < UNDO_TTL_MS) return op;
|
|
7511
|
+
}
|
|
7512
|
+
return null;
|
|
7513
|
+
}
|
|
7493
7514
|
async function deleteSelection() {
|
|
7494
7515
|
try {
|
|
7495
7516
|
const sidebar = await Promise.resolve().then(() => (init_calendar_sidebar(), calendar_sidebar_exports));
|
|
@@ -7512,17 +7533,11 @@ async function deleteSelectedMessages() {
|
|
|
7512
7533
|
const snapshot = [...selected];
|
|
7513
7534
|
removeMessagesAndReconcile(selected);
|
|
7514
7535
|
if (snapshot.length === 1) {
|
|
7515
|
-
|
|
7536
|
+
pushUndo({ kind: "delete", at: Date.now(), payload: { ...snapshot[0], subject: "" } });
|
|
7516
7537
|
if (statusSync) statusSync.textContent = `Trashed 1 message (syncing) \u2014 Ctrl+Z to undo`;
|
|
7517
7538
|
} else {
|
|
7518
|
-
lastDeleted = null;
|
|
7519
7539
|
if (statusSync) statusSync.textContent = `Trashed ${snapshot.length} messages (syncing)`;
|
|
7520
7540
|
}
|
|
7521
|
-
if (undoTimeout) clearTimeout(undoTimeout);
|
|
7522
|
-
undoTimeout = setTimeout(() => {
|
|
7523
|
-
lastDeleted = null;
|
|
7524
|
-
if (statusSync?.textContent?.includes("undo")) statusSync.textContent = "";
|
|
7525
|
-
}, 3e4);
|
|
7526
7541
|
const byAccount = /* @__PURE__ */ new Map();
|
|
7527
7542
|
for (const msg of snapshot) {
|
|
7528
7543
|
const uids = byAccount.get(msg.accountId) || [];
|
|
@@ -7536,52 +7551,48 @@ async function deleteSelectedMessages() {
|
|
|
7536
7551
|
});
|
|
7537
7552
|
}
|
|
7538
7553
|
}
|
|
7539
|
-
async function
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
try {
|
|
7543
|
-
await undeleteMessage(accountId, uid, folderId);
|
|
7544
|
-
const statusSync = document.getElementById("status-sync");
|
|
7545
|
-
if (statusSync) statusSync.textContent = "Message restored";
|
|
7546
|
-
lastDeleted = null;
|
|
7547
|
-
if (undoTimeout) clearTimeout(undoTimeout);
|
|
7548
|
-
reloadCurrentFolder();
|
|
7549
|
-
} catch (e) {
|
|
7550
|
-
console.error(`Undo failed: ${e.message}`);
|
|
7551
|
-
}
|
|
7552
|
-
}
|
|
7553
|
-
async function undoMove() {
|
|
7554
|
-
if (!lastMoved) return;
|
|
7555
|
-
const { messages: messages2 } = lastMoved;
|
|
7554
|
+
async function performUndo() {
|
|
7555
|
+
const op = popUndo();
|
|
7556
|
+
if (!op) return;
|
|
7556
7557
|
const statusSync = document.getElementById("status-sync");
|
|
7557
7558
|
try {
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
if (
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7559
|
+
if (op.kind === "delete") {
|
|
7560
|
+
const { accountId, uid, folderId } = op.payload;
|
|
7561
|
+
await undeleteMessage(accountId, uid, folderId);
|
|
7562
|
+
if (statusSync) statusSync.textContent = "Message restored";
|
|
7563
|
+
} else if (op.kind === "move") {
|
|
7564
|
+
const { messages: messages2 } = op.payload;
|
|
7565
|
+
const byDest = /* @__PURE__ */ new Map();
|
|
7566
|
+
for (const m of messages2) {
|
|
7567
|
+
const key = `${m.accountId}:${m.sourceFolderId}`;
|
|
7568
|
+
if (!byDest.has(key)) byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
|
|
7569
|
+
byDest.get(key).uids.push(m.uid);
|
|
7570
|
+
}
|
|
7571
|
+
const { moveMessages: moveMessages2, moveMessage: moveMessage2 } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
|
|
7572
|
+
for (const group of byDest.values()) {
|
|
7573
|
+
if (group.uids.length === 1) await moveMessage2(group.accountId, group.uids[0], group.folderId);
|
|
7574
|
+
else await moveMessages2(group.accountId, group.uids, group.folderId);
|
|
7575
|
+
}
|
|
7576
|
+
if (statusSync) statusSync.textContent = `Undid move of ${messages2.length} message${messages2.length !== 1 ? "s" : ""}`;
|
|
7577
|
+
} else if (op.kind === "flag") {
|
|
7578
|
+
const { updateFlags: updateFlags2 } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
|
|
7579
|
+
for (const entry of op.payload) {
|
|
7580
|
+
const flag = "\\Flagged";
|
|
7581
|
+
const flagsAfter = entry.prevFlagged ? [flag] : [];
|
|
7582
|
+
await updateFlags2(entry.accountId, entry.uid, flagsAfter);
|
|
7583
|
+
setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged);
|
|
7584
|
+
updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
|
|
7585
|
+
}
|
|
7586
|
+
if (statusSync) statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}`;
|
|
7587
|
+
}
|
|
7572
7588
|
reloadCurrentFolder();
|
|
7573
7589
|
} catch (e) {
|
|
7574
|
-
console.error(`Undo
|
|
7575
|
-
if (statusSync) statusSync.textContent = `Undo
|
|
7590
|
+
console.error(`Undo failed: ${e.message}`);
|
|
7591
|
+
if (statusSync) statusSync.textContent = `Undo failed: ${e.message}`;
|
|
7576
7592
|
}
|
|
7577
7593
|
}
|
|
7578
7594
|
document.addEventListener("mailx-moved", (e) => {
|
|
7579
|
-
|
|
7580
|
-
lastDeleted = null;
|
|
7581
|
-
if (undoTimeout) clearTimeout(undoTimeout);
|
|
7582
|
-
undoTimeout = setTimeout(() => {
|
|
7583
|
-
lastMoved = null;
|
|
7584
|
-
}, 6e4);
|
|
7595
|
+
pushUndo({ kind: "move", at: Date.now(), payload: e.detail });
|
|
7585
7596
|
});
|
|
7586
7597
|
document.getElementById("btn-delete")?.addEventListener("click", deleteSelection);
|
|
7587
7598
|
document.getElementById("btn-tb-delete")?.addEventListener("click", deleteSelection);
|
|
@@ -7595,6 +7606,46 @@ function updateFlagButton() {
|
|
|
7595
7606
|
btn.textContent = yes ? "\u2605" : "\u2606";
|
|
7596
7607
|
}
|
|
7597
7608
|
document.addEventListener("mailx-message-shown", updateFlagButton);
|
|
7609
|
+
async function bulkFlagSelected() {
|
|
7610
|
+
const selected = getSelectedMessages();
|
|
7611
|
+
if (selected.length === 0) {
|
|
7612
|
+
const cur = getCurrentMessage();
|
|
7613
|
+
if (!cur) return;
|
|
7614
|
+
selected.push({ accountId: cur.accountId, uid: cur.message.uid, folderId: cur.message.folderId });
|
|
7615
|
+
}
|
|
7616
|
+
const statusSync = document.getElementById("status-sync");
|
|
7617
|
+
const messages2 = getMessages2();
|
|
7618
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
7619
|
+
for (const m of messages2) byKey.set(`${m.accountId}:${m.uid}`, m);
|
|
7620
|
+
const rows = selected.map((s) => byKey.get(`${s.accountId}:${s.uid}`)).filter(Boolean);
|
|
7621
|
+
if (rows.length === 0) return;
|
|
7622
|
+
const anyUnflagged = rows.some((r) => !flaggedOf(r));
|
|
7623
|
+
const targetFlagged = anyUnflagged;
|
|
7624
|
+
const undoPayload = rows.map((r) => ({
|
|
7625
|
+
accountId: r.accountId,
|
|
7626
|
+
uid: r.uid,
|
|
7627
|
+
prevFlagged: flaggedOf(r)
|
|
7628
|
+
}));
|
|
7629
|
+
const { updateFlags: updateFlags2 } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
|
|
7630
|
+
for (const r of rows) {
|
|
7631
|
+
if (flaggedOf(r) === targetFlagged) continue;
|
|
7632
|
+
setFlagged(r, targetFlagged);
|
|
7633
|
+
setRowFlagged(r.accountId, r.uid, targetFlagged);
|
|
7634
|
+
updateMessageFlags(r.accountId, r.uid, r.flags);
|
|
7635
|
+
try {
|
|
7636
|
+
await updateFlags2(r.accountId, r.uid, r.flags);
|
|
7637
|
+
} catch (e) {
|
|
7638
|
+
console.error(`Bulk flag failed for ${r.accountId}/${r.uid}: ${e?.message || e}`);
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
pushUndo({ kind: "flag", at: Date.now(), payload: undoPayload });
|
|
7642
|
+
updateFlagButton();
|
|
7643
|
+
if (statusSync) {
|
|
7644
|
+
const verb = targetFlagged ? "Flagged" : "Unflagged";
|
|
7645
|
+
statusSync.textContent = `${verb} ${rows.length} message${rows.length !== 1 ? "s" : ""} \u2014 Ctrl+Z to undo`;
|
|
7646
|
+
}
|
|
7647
|
+
}
|
|
7648
|
+
document.getElementById("btn-tb-flag")?.addEventListener("click", bulkFlagSelected);
|
|
7598
7649
|
document.getElementById("btn-flag")?.addEventListener("click", async () => {
|
|
7599
7650
|
const sel = getCurrentFocused();
|
|
7600
7651
|
if (!sel) return;
|
|
@@ -8814,12 +8865,9 @@ document.addEventListener("keydown", (e) => {
|
|
|
8814
8865
|
deleteSelection();
|
|
8815
8866
|
}
|
|
8816
8867
|
if (e.ctrlKey && e.key === "z") {
|
|
8817
|
-
if (
|
|
8818
|
-
e.preventDefault();
|
|
8819
|
-
undoMove();
|
|
8820
|
-
} else if (lastDeleted) {
|
|
8868
|
+
if (undoStack.length > 0) {
|
|
8821
8869
|
e.preventDefault();
|
|
8822
|
-
|
|
8870
|
+
performUndo();
|
|
8823
8871
|
}
|
|
8824
8872
|
}
|
|
8825
8873
|
if (e.key === "F5") {
|
|
@@ -9885,13 +9933,28 @@ optThreaded?.addEventListener("change", () => {
|
|
|
9885
9933
|
localStorage.setItem("mailx-threaded", String(optThreaded.checked));
|
|
9886
9934
|
reloadCurrentFolder();
|
|
9887
9935
|
});
|
|
9888
|
-
|
|
9936
|
+
function syncFilterFlaggedButton() {
|
|
9937
|
+
const btn = document.getElementById("btn-filter-flagged");
|
|
9938
|
+
if (!btn) return;
|
|
9939
|
+
btn.classList.toggle("active", !!optFlagged?.checked);
|
|
9940
|
+
btn.setAttribute("aria-pressed", optFlagged?.checked ? "true" : "false");
|
|
9941
|
+
}
|
|
9942
|
+
function applyFlaggedFilter(on) {
|
|
9943
|
+
if (optFlagged) optFlagged.checked = on;
|
|
9889
9944
|
const body = document.getElementById("ml-body");
|
|
9890
|
-
if (
|
|
9945
|
+
if (on) body?.classList.add("flagged-only");
|
|
9891
9946
|
else body?.classList.remove("flagged-only");
|
|
9892
|
-
localStorage.setItem("mailx-flagged", String(
|
|
9947
|
+
localStorage.setItem("mailx-flagged", String(on));
|
|
9948
|
+
syncFilterFlaggedButton();
|
|
9893
9949
|
reloadCurrentFolder();
|
|
9950
|
+
}
|
|
9951
|
+
optFlagged?.addEventListener("change", () => {
|
|
9952
|
+
applyFlaggedFilter(!!optFlagged.checked);
|
|
9953
|
+
});
|
|
9954
|
+
document.getElementById("btn-filter-flagged")?.addEventListener("click", () => {
|
|
9955
|
+
applyFlaggedFilter(!optFlagged?.checked);
|
|
9894
9956
|
});
|
|
9957
|
+
syncFilterFlaggedButton();
|
|
9895
9958
|
var optPriorityOnly = document.getElementById("opt-priority-only");
|
|
9896
9959
|
if (optPriorityOnly) {
|
|
9897
9960
|
optPriorityOnly.checked = localStorage.getItem("mailx-priority-only") === "true";
|