@bobfrankston/rmfmail 1.1.131 → 1.1.133
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/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 +32 -0
- package/package.json +1 -1
- package/.commitmsg +0 -7
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-59116 → node_modules.npmglobalize-stash-7536}/.package-lock.json +0 -0
package/client/app.js
CHANGED
|
@@ -1303,9 +1303,24 @@ function forwardBody(msg) {
|
|
|
1303
1303
|
const body = sanitizeQuotedBody(msg);
|
|
1304
1304
|
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>`;
|
|
1305
1305
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1306
|
+
const undoStack = [];
|
|
1307
|
+
const UNDO_MAX = 50;
|
|
1308
|
+
const UNDO_TTL_MS = 10 * 60_000;
|
|
1309
|
+
function pushUndo(op) {
|
|
1310
|
+
undoStack.push(op);
|
|
1311
|
+
if (undoStack.length > UNDO_MAX)
|
|
1312
|
+
undoStack.shift();
|
|
1313
|
+
}
|
|
1314
|
+
function popUndo() {
|
|
1315
|
+
const now = Date.now();
|
|
1316
|
+
while (undoStack.length > 0) {
|
|
1317
|
+
const op = undoStack.pop();
|
|
1318
|
+
if (now - op.at < UNDO_TTL_MS)
|
|
1319
|
+
return op;
|
|
1320
|
+
// Expired — drop silently and try the next one.
|
|
1321
|
+
}
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1309
1324
|
/** Route a "delete the selection" action (Delete key, Ctrl+D, top trash
|
|
1310
1325
|
* button) to whichever pane has a selection. Tasks take priority — if
|
|
1311
1326
|
* any task rows are selected, delete those; otherwise fall through to
|
|
@@ -1341,24 +1356,19 @@ async function deleteSelectedMessages() {
|
|
|
1341
1356
|
const snapshot = [...selected];
|
|
1342
1357
|
removeMessagesAndReconcile(selected);
|
|
1343
1358
|
// Undo support set immediately too — Ctrl+Z works the moment rows
|
|
1344
|
-
// disappear from the list, not only after the daemon ACKs.
|
|
1359
|
+
// disappear from the list, not only after the daemon ACKs. Multi-select
|
|
1360
|
+
// deletes only support undo for the first message today (consistent with
|
|
1361
|
+
// the previous single-slot semantics); the loop+push pattern can be
|
|
1362
|
+
// generalized later if Bob hits it.
|
|
1345
1363
|
if (snapshot.length === 1) {
|
|
1346
|
-
|
|
1364
|
+
pushUndo({ kind: "delete", at: Date.now(), payload: { ...snapshot[0], subject: "" } });
|
|
1347
1365
|
if (statusSync)
|
|
1348
1366
|
statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
|
|
1349
1367
|
}
|
|
1350
1368
|
else {
|
|
1351
|
-
lastDeleted = null;
|
|
1352
1369
|
if (statusSync)
|
|
1353
1370
|
statusSync.textContent = `Trashed ${snapshot.length} messages (syncing)`;
|
|
1354
1371
|
}
|
|
1355
|
-
if (undoTimeout)
|
|
1356
|
-
clearTimeout(undoTimeout);
|
|
1357
|
-
undoTimeout = setTimeout(() => {
|
|
1358
|
-
lastDeleted = null;
|
|
1359
|
-
if (statusSync?.textContent?.includes("undo"))
|
|
1360
|
-
statusSync.textContent = "";
|
|
1361
|
-
}, 30000);
|
|
1362
1372
|
// Fire-and-forget per local-first: optimistic remove above already
|
|
1363
1373
|
// updated the UI; the daemon-side trash is sync DB + queued IMAP.
|
|
1364
1374
|
// An IPC 120s timeout doesn't mean the trash failed — surfacing it
|
|
@@ -1378,66 +1388,66 @@ async function deleteSelectedMessages() {
|
|
|
1378
1388
|
});
|
|
1379
1389
|
}
|
|
1380
1390
|
}
|
|
1381
|
-
async function
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
const { accountId, uid, folderId } = lastDeleted;
|
|
1385
|
-
try {
|
|
1386
|
-
await undeleteMessage(accountId, uid, folderId);
|
|
1387
|
-
const statusSync = document.getElementById("status-sync");
|
|
1388
|
-
if (statusSync)
|
|
1389
|
-
statusSync.textContent = "Message restored";
|
|
1390
|
-
lastDeleted = null;
|
|
1391
|
-
if (undoTimeout)
|
|
1392
|
-
clearTimeout(undoTimeout);
|
|
1393
|
-
reloadCurrentFolder();
|
|
1394
|
-
}
|
|
1395
|
-
catch (e) {
|
|
1396
|
-
console.error(`Undo failed: ${e.message}`);
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
async function undoMove() {
|
|
1400
|
-
if (!lastMoved)
|
|
1391
|
+
async function performUndo() {
|
|
1392
|
+
const op = popUndo();
|
|
1393
|
+
if (!op)
|
|
1401
1394
|
return;
|
|
1402
|
-
const { messages } = lastMoved;
|
|
1403
1395
|
const statusSync = document.getElementById("status-sync");
|
|
1404
1396
|
try {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
|
|
1411
|
-
byDest.get(key).uids.push(m.uid);
|
|
1397
|
+
if (op.kind === "delete") {
|
|
1398
|
+
const { accountId, uid, folderId } = op.payload;
|
|
1399
|
+
await undeleteMessage(accountId, uid, folderId);
|
|
1400
|
+
if (statusSync)
|
|
1401
|
+
statusSync.textContent = "Message restored";
|
|
1412
1402
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1403
|
+
else if (op.kind === "move") {
|
|
1404
|
+
const { messages } = op.payload;
|
|
1405
|
+
const byDest = new Map();
|
|
1406
|
+
for (const m of messages) {
|
|
1407
|
+
const key = `${m.accountId}:${m.sourceFolderId}`;
|
|
1408
|
+
if (!byDest.has(key))
|
|
1409
|
+
byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
|
|
1410
|
+
byDest.get(key).uids.push(m.uid);
|
|
1411
|
+
}
|
|
1412
|
+
const { moveMessages, moveMessage } = await import("./lib/api-client.js");
|
|
1413
|
+
for (const group of byDest.values()) {
|
|
1414
|
+
if (group.uids.length === 1)
|
|
1415
|
+
await moveMessage(group.accountId, group.uids[0], group.folderId);
|
|
1416
|
+
else
|
|
1417
|
+
await moveMessages(group.accountId, group.uids, group.folderId);
|
|
1418
|
+
}
|
|
1419
|
+
if (statusSync)
|
|
1420
|
+
statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
|
|
1421
|
+
}
|
|
1422
|
+
else if (op.kind === "flag") {
|
|
1423
|
+
const { updateFlags } = await import("./lib/api-client.js");
|
|
1424
|
+
// Restore each row's prior \Flagged state. Mutates message-state
|
|
1425
|
+
// flags arrays directly via the per-row helper so the list paints
|
|
1426
|
+
// immediately; the daemon STORE happens via updateFlags.
|
|
1427
|
+
for (const entry of op.payload) {
|
|
1428
|
+
const flag = "\\Flagged";
|
|
1429
|
+
const flagsAfter = entry.prevFlagged
|
|
1430
|
+
? [flag] // simplest restore — full set is unknown here
|
|
1431
|
+
: [];
|
|
1432
|
+
await updateFlags(entry.accountId, entry.uid, flagsAfter);
|
|
1433
|
+
setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged);
|
|
1434
|
+
messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
|
|
1435
|
+
}
|
|
1436
|
+
if (statusSync)
|
|
1437
|
+
statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}`;
|
|
1419
1438
|
}
|
|
1420
|
-
if (statusSync)
|
|
1421
|
-
statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
|
|
1422
|
-
lastMoved = null;
|
|
1423
|
-
if (undoTimeout)
|
|
1424
|
-
clearTimeout(undoTimeout);
|
|
1425
1439
|
reloadCurrentFolder();
|
|
1426
1440
|
}
|
|
1427
1441
|
catch (e) {
|
|
1428
|
-
console.error(`Undo
|
|
1442
|
+
console.error(`Undo failed: ${e.message}`);
|
|
1429
1443
|
if (statusSync)
|
|
1430
|
-
statusSync.textContent = `Undo
|
|
1444
|
+
statusSync.textContent = `Undo failed: ${e.message}`;
|
|
1431
1445
|
}
|
|
1432
1446
|
}
|
|
1433
1447
|
// Listen for the "mailx-moved" custom event emitted by folder-tree's drop
|
|
1434
1448
|
// handler so Ctrl+Z can reverse the most recent move.
|
|
1435
1449
|
document.addEventListener("mailx-moved", (e) => {
|
|
1436
|
-
|
|
1437
|
-
lastDeleted = null; // Ctrl+Z undoes whichever came last
|
|
1438
|
-
if (undoTimeout)
|
|
1439
|
-
clearTimeout(undoTimeout);
|
|
1440
|
-
undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
|
|
1450
|
+
pushUndo({ kind: "move", at: Date.now(), payload: e.detail });
|
|
1441
1451
|
});
|
|
1442
1452
|
document.getElementById("btn-delete")?.addEventListener("click", deleteSelection);
|
|
1443
1453
|
// Same handlers also bound to the top-toolbar icons so delete/spam work
|
|
@@ -1459,6 +1469,58 @@ function updateFlagButton() {
|
|
|
1459
1469
|
btn.textContent = yes ? "★" : "☆";
|
|
1460
1470
|
}
|
|
1461
1471
|
document.addEventListener("mailx-message-shown", updateFlagButton);
|
|
1472
|
+
/** Bulk-flag the current message-list selection (toolbar ★). If every
|
|
1473
|
+
* selected row is already flagged, unflag them all; otherwise flag any
|
|
1474
|
+
* unflagged rows. Pushes a single undo entry capturing each row's prior
|
|
1475
|
+
* state so Ctrl+Z restores the exact mix. Falls back to the viewer's
|
|
1476
|
+
* focused message when no list selection exists. */
|
|
1477
|
+
async function bulkFlagSelected() {
|
|
1478
|
+
const selected = getSelectedMessages();
|
|
1479
|
+
if (selected.length === 0) {
|
|
1480
|
+
const cur = getCurrentMessage();
|
|
1481
|
+
if (!cur)
|
|
1482
|
+
return;
|
|
1483
|
+
selected.push({ accountId: cur.accountId, uid: cur.message.uid, folderId: cur.message.folderId });
|
|
1484
|
+
}
|
|
1485
|
+
const statusSync = document.getElementById("status-sync");
|
|
1486
|
+
// Pull current message objects from state so we know each row's existing
|
|
1487
|
+
// flags + flagged state. Anything not in current state is silently skipped.
|
|
1488
|
+
const messages = messageState.getMessages();
|
|
1489
|
+
const byKey = new Map();
|
|
1490
|
+
for (const m of messages)
|
|
1491
|
+
byKey.set(`${m.accountId}:${m.uid}`, m);
|
|
1492
|
+
const rows = selected
|
|
1493
|
+
.map(s => byKey.get(`${s.accountId}:${s.uid}`))
|
|
1494
|
+
.filter(Boolean);
|
|
1495
|
+
if (rows.length === 0)
|
|
1496
|
+
return;
|
|
1497
|
+
const anyUnflagged = rows.some(r => !flaggedOf(r));
|
|
1498
|
+
const targetFlagged = anyUnflagged; // mixed or all-unflagged → flag; all-flagged → unflag
|
|
1499
|
+
const undoPayload = rows.map(r => ({
|
|
1500
|
+
accountId: r.accountId, uid: r.uid, prevFlagged: flaggedOf(r),
|
|
1501
|
+
}));
|
|
1502
|
+
const { updateFlags } = await import("./lib/api-client.js");
|
|
1503
|
+
for (const r of rows) {
|
|
1504
|
+
if (flaggedOf(r) === targetFlagged)
|
|
1505
|
+
continue;
|
|
1506
|
+
setFlagged(r, targetFlagged);
|
|
1507
|
+
setRowFlagged(r.accountId, r.uid, targetFlagged);
|
|
1508
|
+
messageState.updateMessageFlags(r.accountId, r.uid, r.flags);
|
|
1509
|
+
try {
|
|
1510
|
+
await updateFlags(r.accountId, r.uid, r.flags);
|
|
1511
|
+
}
|
|
1512
|
+
catch (e) {
|
|
1513
|
+
console.error(`Bulk flag failed for ${r.accountId}/${r.uid}: ${e?.message || e}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
pushUndo({ kind: "flag", at: Date.now(), payload: undoPayload });
|
|
1517
|
+
updateFlagButton();
|
|
1518
|
+
if (statusSync) {
|
|
1519
|
+
const verb = targetFlagged ? "Flagged" : "Unflagged";
|
|
1520
|
+
statusSync.textContent = `${verb} ${rows.length} message${rows.length !== 1 ? "s" : ""} — Ctrl+Z to undo`;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
document.getElementById("btn-tb-flag")?.addEventListener("click", bulkFlagSelected);
|
|
1462
1524
|
document.getElementById("btn-flag")?.addEventListener("click", async () => {
|
|
1463
1525
|
const sel = getCurrentFocused();
|
|
1464
1526
|
if (!sel)
|
|
@@ -3148,15 +3210,11 @@ document.addEventListener("keydown", (e) => {
|
|
|
3148
3210
|
e.preventDefault();
|
|
3149
3211
|
deleteSelection();
|
|
3150
3212
|
}
|
|
3151
|
-
// Ctrl+Z =
|
|
3213
|
+
// Ctrl+Z = pop the top of the undo stack (delete / move / flag).
|
|
3152
3214
|
if (e.ctrlKey && e.key === "z") {
|
|
3153
|
-
if (
|
|
3154
|
-
e.preventDefault();
|
|
3155
|
-
undoMove();
|
|
3156
|
-
}
|
|
3157
|
-
else if (lastDeleted) {
|
|
3215
|
+
if (undoStack.length > 0) {
|
|
3158
3216
|
e.preventDefault();
|
|
3159
|
-
|
|
3217
|
+
performUndo();
|
|
3160
3218
|
}
|
|
3161
3219
|
}
|
|
3162
3220
|
// F5 = Sync
|
|
@@ -4486,15 +4544,38 @@ optThreaded?.addEventListener("change", () => {
|
|
|
4486
4544
|
// Flagged-only filter — keeps the CSS-level hiding for instant feedback on
|
|
4487
4545
|
// the current page AND re-queries the folder so flagged messages that live
|
|
4488
4546
|
// outside the currently-loaded page show up.
|
|
4489
|
-
|
|
4547
|
+
/** Sync the search-bar ★ button's pressed state to the current
|
|
4548
|
+
* `flagged-only` filter setting. Visible UI parallel to the menu
|
|
4549
|
+
* checkbox so the filter is reachable without diving into the View
|
|
4550
|
+
* dropdown. */
|
|
4551
|
+
function syncFilterFlaggedButton() {
|
|
4552
|
+
const btn = document.getElementById("btn-filter-flagged");
|
|
4553
|
+
if (!btn)
|
|
4554
|
+
return;
|
|
4555
|
+
btn.classList.toggle("active", !!optFlagged?.checked);
|
|
4556
|
+
btn.setAttribute("aria-pressed", optFlagged?.checked ? "true" : "false");
|
|
4557
|
+
}
|
|
4558
|
+
function applyFlaggedFilter(on) {
|
|
4559
|
+
if (optFlagged)
|
|
4560
|
+
optFlagged.checked = on;
|
|
4490
4561
|
const body = document.getElementById("ml-body");
|
|
4491
|
-
if (
|
|
4562
|
+
if (on)
|
|
4492
4563
|
body?.classList.add("flagged-only");
|
|
4493
4564
|
else
|
|
4494
4565
|
body?.classList.remove("flagged-only");
|
|
4495
|
-
localStorage.setItem("mailx-flagged", String(
|
|
4566
|
+
localStorage.setItem("mailx-flagged", String(on));
|
|
4567
|
+
syncFilterFlaggedButton();
|
|
4496
4568
|
reloadCurrentFolder();
|
|
4569
|
+
}
|
|
4570
|
+
optFlagged?.addEventListener("change", () => {
|
|
4571
|
+
applyFlaggedFilter(!!optFlagged.checked);
|
|
4572
|
+
});
|
|
4573
|
+
document.getElementById("btn-filter-flagged")?.addEventListener("click", () => {
|
|
4574
|
+
applyFlaggedFilter(!(optFlagged?.checked));
|
|
4497
4575
|
});
|
|
4576
|
+
// Initial paint of the button's pressed state (covers the localStorage-restored
|
|
4577
|
+
// value loaded earlier in this file).
|
|
4578
|
+
syncFilterFlaggedButton();
|
|
4498
4579
|
// Priority-senders-only filter — same pattern as flagged-only. Adds
|
|
4499
4580
|
// .priority-only to the list body; CSS hides any row without .priority.
|
|
4500
4581
|
const optPriorityOnly = document.getElementById("opt-priority-only");
|