@bobfrankston/rmfmail 1.2.214 → 1.2.216

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
@@ -1790,11 +1790,24 @@ async function deleteSelectedMessages() {
1790
1790
  });
1791
1791
  }
1792
1792
  }
1793
+ /** Serialize Ctrl+Z presses. Each undo is several awaited IPCs plus a
1794
+ * revealMessage + folder reload; firing them concurrently (hold Ctrl and tap Z
1795
+ * three times to walk back three deletions) had them interleaving — two
1796
+ * reveals fighting over the list position, reloads landing out of order. The
1797
+ * stack is LIFO and each queued call pops its own entry when its turn comes,
1798
+ * so N presses undo the N most recent actions, in order. */
1799
+ let undoChain = Promise.resolve();
1800
+ function queueUndo() {
1801
+ undoChain = undoChain.then(() => performUndo()).catch(() => { });
1802
+ }
1793
1803
  async function performUndo() {
1794
1804
  const op = popUndo();
1795
1805
  if (!op)
1796
1806
  return;
1797
1807
  const statusSync = document.getElementById("status-sync");
1808
+ // "3 more to undo" — without it, a stack of undone deletions is invisible
1809
+ // and the user can't tell whether pressing Ctrl+Z again will do anything.
1810
+ const remaining = undoStack.length ? ` — ${undoStack.length} more to undo` : "";
1798
1811
  try {
1799
1812
  if (op.kind === "delete") {
1800
1813
  // payload is the full batch (was a single object pre-2026-06-12;
@@ -1805,9 +1818,9 @@ async function performUndo() {
1805
1818
  await undeleteMessage(m.accountId, m.uid, m.folderId);
1806
1819
  }
1807
1820
  if (statusSync)
1808
- statusSync.textContent = msgs.length === 1
1821
+ statusSync.textContent = (msgs.length === 1
1809
1822
  ? "Message restored"
1810
- : `Restored ${msgs.length} messages`;
1823
+ : `Restored ${msgs.length} messages`) + remaining;
1811
1824
  // Position at what was just restored — an undo whose effect lands
1812
1825
  // off-screen (or in a folder you've since left) reads as a no-op.
1813
1826
  // Reveal the first of the batch; the rest are in the same view.
@@ -1836,7 +1849,7 @@ async function performUndo() {
1836
1849
  await moveMessages(group.accountId, group.uids, group.folderId);
1837
1850
  }
1838
1851
  if (statusSync)
1839
- statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
1852
+ statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}${remaining}`;
1840
1853
  // Same positioning rule as undo-delete: show the message back in
1841
1854
  // its original folder.
1842
1855
  const firstMoved = messages[0];
@@ -1861,7 +1874,7 @@ async function performUndo() {
1861
1874
  messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
1862
1875
  }
1863
1876
  if (statusSync)
1864
- statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}`;
1877
+ statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}${remaining}`;
1865
1878
  }
1866
1879
  reloadCurrentFolder();
1867
1880
  }
@@ -2170,8 +2183,26 @@ async function spamSelectedMessages() {
2170
2183
  g.folderIds.push(msg.folderId);
2171
2184
  byAccount.set(msg.accountId, g);
2172
2185
  }
2186
+ // Marking spam is just a move to the \Junk folder (service-side
2187
+ // markAsSpamMessages), so its inverse is the move back — the same undo op
2188
+ // a drag-to-folder move records. Pushed BEFORE the IPC, not in its `.then`:
2189
+ // a misfired spam click is exactly the case where the user reaches for
2190
+ // Ctrl+Z immediately, and the local move has already happened by then
2191
+ // (Bob 2026-08-03: "^z should undo send-to-spam"). The move branch of
2192
+ // performUndo restores each message to its own sourceFolderId, so a
2193
+ // selection spanning folders comes back correctly; targetFolderId is only
2194
+ // known once the service answers and the undo doesn't need it.
2195
+ pushUndo({
2196
+ kind: "move",
2197
+ at: Date.now(),
2198
+ payload: {
2199
+ messages: snapshot.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
2200
+ targetAccountId: snapshot[0].accountId,
2201
+ targetFolderId: 0,
2202
+ },
2203
+ });
2173
2204
  if (statusSync)
2174
- statusSync.textContent = `Spam: ${snapshot.length} queued — pending server sync`;
2205
+ statusSync.textContent = `Spam: ${snapshot.length} queued — Ctrl+Z to undo`;
2175
2206
  for (const [accountId, { uids, folderIds }] of byAccount) {
2176
2207
  markAsSpamMessages(accountId, uids, folderIds)
2177
2208
  .then(result => {
@@ -4094,7 +4125,7 @@ document.addEventListener("keydown", (e) => {
4094
4125
  const composeOpen = !!document.querySelector(".compose-overlay");
4095
4126
  if (!inEditable && !composeOpen && undoStack.length > 0) {
4096
4127
  e.preventDefault();
4097
- performUndo();
4128
+ queueUndo();
4098
4129
  }
4099
4130
  }
4100
4131
  // F5 = Sync
@@ -4333,7 +4364,7 @@ function openShortcutsDialog() {
4333
4364
  ["Forward", "Ctrl+F"],
4334
4365
  ["Sync", "F5"],
4335
4366
  ["Delete selected", "Del or Ctrl+D"],
4336
- ["Undo last delete/move", "Ctrl+Z"],
4367
+ ["Undo delete / move / spam / flag (repeat to go back further)", "Ctrl+Z"],
4337
4368
  ["Toggle read/unread", "R"],
4338
4369
  ["Toggle flag (★)", "(⚑ button in viewer)"],
4339
4370
  ["Select all visible", "Ctrl+A"],