@bobfrankston/rmfmail 1.1.130 → 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/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
- let lastDeleted = null;
1307
- let lastMoved = null;
1308
- let undoTimeout = null;
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
- lastDeleted = { ...snapshot[0], subject: "" };
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 undoDelete() {
1382
- if (!lastDeleted)
1383
- return;
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
- // Group by (sourceAccountId, sourceFolderId) and move each group back
1406
- const byDest = new Map();
1407
- for (const m of messages) {
1408
- const key = `${m.accountId}:${m.sourceFolderId}`;
1409
- if (!byDest.has(key))
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
- const { moveMessages, moveMessage } = await import("./lib/api-client.js");
1414
- for (const group of byDest.values()) {
1415
- if (group.uids.length === 1)
1416
- await moveMessage(group.accountId, group.uids[0], group.folderId);
1417
- else
1418
- await moveMessages(group.accountId, group.uids, group.folderId);
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 move failed: ${e.message}`);
1442
+ console.error(`Undo failed: ${e.message}`);
1429
1443
  if (statusSync)
1430
- statusSync.textContent = `Undo move failed: ${e.message}`;
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
- lastMoved = e.detail;
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 = Undo the most recent delete or move
3213
+ // Ctrl+Z = pop the top of the undo stack (delete / move / flag).
3152
3214
  if (e.ctrlKey && e.key === "z") {
3153
- if (lastMoved) {
3154
- e.preventDefault();
3155
- undoMove();
3156
- }
3157
- else if (lastDeleted) {
3215
+ if (undoStack.length > 0) {
3158
3216
  e.preventDefault();
3159
- undoDelete();
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
- optFlagged?.addEventListener("change", () => {
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 (optFlagged.checked)
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(optFlagged.checked));
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");