@bobfrankston/rmfmail 1.2.215 → 1.2.217

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.ts CHANGED
@@ -1758,10 +1758,24 @@ async function deleteSelectedMessages(): Promise<void> {
1758
1758
  }
1759
1759
  }
1760
1760
 
1761
+ /** Serialize Ctrl+Z presses. Each undo is several awaited IPCs plus a
1762
+ * revealMessage + folder reload; firing them concurrently (hold Ctrl and tap Z
1763
+ * three times to walk back three deletions) had them interleaving — two
1764
+ * reveals fighting over the list position, reloads landing out of order. The
1765
+ * stack is LIFO and each queued call pops its own entry when its turn comes,
1766
+ * so N presses undo the N most recent actions, in order. */
1767
+ let undoChain: Promise<void> = Promise.resolve();
1768
+ function queueUndo(): void {
1769
+ undoChain = undoChain.then(() => performUndo()).catch(() => { /* performUndo reports its own failures */ });
1770
+ }
1771
+
1761
1772
  async function performUndo(): Promise<void> {
1762
1773
  const op = popUndo();
1763
1774
  if (!op) return;
1764
1775
  const statusSync = document.getElementById("status-sync");
1776
+ // "3 more to undo" — without it, a stack of undone deletions is invisible
1777
+ // and the user can't tell whether pressing Ctrl+Z again will do anything.
1778
+ const remaining = undoStack.length ? ` — ${undoStack.length} more to undo` : "";
1765
1779
  try {
1766
1780
  if (op.kind === "delete") {
1767
1781
  // payload is the full batch (was a single object pre-2026-06-12;
@@ -1771,9 +1785,9 @@ async function performUndo(): Promise<void> {
1771
1785
  for (const m of msgs) {
1772
1786
  await undeleteMessage(m.accountId, m.uid, m.folderId);
1773
1787
  }
1774
- if (statusSync) statusSync.textContent = msgs.length === 1
1788
+ if (statusSync) statusSync.textContent = (msgs.length === 1
1775
1789
  ? "Message restored"
1776
- : `Restored ${msgs.length} messages`;
1790
+ : `Restored ${msgs.length} messages`) + remaining;
1777
1791
  // Position at what was just restored — an undo whose effect lands
1778
1792
  // off-screen (or in a folder you've since left) reads as a no-op.
1779
1793
  // Reveal the first of the batch; the rest are in the same view.
@@ -1785,19 +1799,24 @@ async function performUndo(): Promise<void> {
1785
1799
  return;
1786
1800
  }
1787
1801
  } else if (op.kind === "move") {
1788
- const { messages } = op.payload;
1802
+ const { messages, targetFolderId } = op.payload;
1789
1803
  const byDest = new Map<string, { accountId: string; folderId: number; uids: number[] }>();
1790
1804
  for (const m of messages) {
1791
1805
  const key = `${m.accountId}:${m.sourceFolderId}`;
1792
1806
  if (!byDest.has(key)) byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
1793
1807
  byDest.get(key)!.uids.push(m.uid);
1794
1808
  }
1795
- const { moveMessages, moveMessage } = await import("./lib/api-client.js");
1809
+ const { moveMessages } = await import("./lib/api-client.js");
1796
1810
  for (const group of byDest.values()) {
1797
- if (group.uids.length === 1) await moveMessage(group.accountId, group.uids[0], group.folderId);
1798
- else await moveMessages(group.accountId, group.uids, group.folderId);
1811
+ // The messages are sitting in targetFolderId right now, so that
1812
+ // is their SOURCE for the reverse move — pass it explicitly.
1813
+ // Without it the service resolves each row by (account, uid)
1814
+ // alone and a same-numbered uid in another folder can be moved
1815
+ // instead (the flag-clear / spam-move wrong-folder class).
1816
+ const currentFolderIds = targetFolderId ? group.uids.map(() => targetFolderId) : undefined;
1817
+ await moveMessages(group.accountId, group.uids, group.folderId, currentFolderIds);
1799
1818
  }
1800
- if (statusSync) statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
1819
+ if (statusSync) statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}${remaining}`;
1801
1820
  // Same positioning rule as undo-delete: show the message back in
1802
1821
  // its original folder.
1803
1822
  const firstMoved = messages[0];
@@ -1820,7 +1839,7 @@ async function performUndo(): Promise<void> {
1820
1839
  setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged);
1821
1840
  messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
1822
1841
  }
1823
- if (statusSync) statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}`;
1842
+ if (statusSync) statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}${remaining}`;
1824
1843
  }
1825
1844
  reloadCurrentFolder();
1826
1845
  } catch (e: any) {
@@ -2107,11 +2126,30 @@ async function spamSelectedMessages(): Promise<void> {
2107
2126
  g.folderIds.push(msg.folderId);
2108
2127
  byAccount.set(msg.accountId, g);
2109
2128
  }
2110
- if (statusSync) statusSync.textContent = `Spam: ${snapshot.length} queued pending server sync`;
2129
+ // Marking spam is just a move to the \Junk folder (service-side
2130
+ // markAsSpamMessages), so its inverse is the move back — the same undo op
2131
+ // a drag-to-folder move records. Pushed BEFORE the IPC, not in its `.then`:
2132
+ // a misfired spam click is exactly the case where the user reaches for
2133
+ // Ctrl+Z immediately, and the local move has already happened by then
2134
+ // (Bob 2026-08-03: "^z should undo send-to-spam"). The move branch of
2135
+ // performUndo restores each message to its own sourceFolderId, so a
2136
+ // selection spanning folders comes back correctly; targetFolderId is only
2137
+ // known once the service answers and the undo doesn't need it.
2138
+ const spamUndo: MovedBatch = {
2139
+ messages: snapshot.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
2140
+ targetAccountId: snapshot[0].accountId,
2141
+ targetFolderId: 0, // filled in below — only the service knows which folder is \Junk
2142
+ };
2143
+ pushUndo({ kind: "move", at: Date.now(), payload: spamUndo });
2144
+ if (statusSync) statusSync.textContent = `Spam: ${snapshot.length} queued — Ctrl+Z to undo`;
2111
2145
  for (const [accountId, { uids, folderIds }] of byAccount) {
2112
2146
  markAsSpamMessages(accountId, uids, folderIds)
2113
2147
  .then(result => {
2114
2148
  console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);
2149
+ // Now we know where they landed. The undo op is still on the
2150
+ // stack (same object) — record it so the reverse move names the
2151
+ // messages' current folder instead of guessing by uid alone.
2152
+ if (result?.targetFolderId) spamUndo.targetFolderId = result.targetFolderId;
2115
2153
  })
2116
2154
  .catch(e => {
2117
2155
  console.error(`[spam] ${accountId} failed:`, e);
@@ -3899,7 +3937,7 @@ document.addEventListener("keydown", (e) => {
3899
3937
  const composeOpen = !!document.querySelector(".compose-overlay");
3900
3938
  if (!inEditable && !composeOpen && undoStack.length > 0) {
3901
3939
  e.preventDefault();
3902
- performUndo();
3940
+ queueUndo();
3903
3941
  }
3904
3942
  }
3905
3943
  // F5 = Sync
@@ -4113,7 +4151,7 @@ function openShortcutsDialog(): void {
4113
4151
  ["Forward", "Ctrl+F"],
4114
4152
  ["Sync", "F5"],
4115
4153
  ["Delete selected", "Del or Ctrl+D"],
4116
- ["Undo last delete/move", "Ctrl+Z"],
4154
+ ["Undo delete / move / spam / flag (repeat to go back further)", "Ctrl+Z"],
4117
4155
  ["Toggle read/unread", "R"],
4118
4156
  ["Toggle flag (★)", "(⚑ button in viewer)"],
4119
4157
  ["Select all visible", "Ctrl+A"],
@@ -1357,8 +1357,9 @@ function deleteMessages(accountId, uids, folderIds) {
1357
1357
  return ipc().deleteMessages?.(accountId, uids, folderIds);
1358
1358
  }
1359
1359
  function moveMessages(accountId, uids, targetFolderId, folderIds, targetAccountId) {
1360
- if (uids.length === 1)
1360
+ if (uids.length === 1 && (targetAccountId || !folderIds?.length)) {
1361
1361
  return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);
1362
+ }
1362
1363
  return ipc().moveMessages?.(accountId, uids, targetFolderId, folderIds);
1363
1364
  }
1364
1365
  function markAsSpamMessages(accountId, uids, folderIds) {