From e674954c1b09c8f1b9ba8ec778ae25e363413e2a Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Fri, 31 Jul 2026 07:35:20 -0700
Subject: [PATCH] feat: undo inbox triage actions
---
backend/src/routes/gtd.classify.test.js | 52 ++-
backend/src/routes/gtd.js | 22 +-
frontend/src/components/MailApp.jsx | 12 +-
frontend/src/components/MessageList.jsx | 226 ++++++++------
.../src/components/NotificationToasts.jsx | 62 ++--
frontend/src/locales/de.json | 3 +
frontend/src/locales/en.json | 3 +
frontend/src/locales/es.json | 3 +
frontend/src/locales/fr.json | 3 +
frontend/src/locales/it.json | 3 +
frontend/src/locales/ru.json | 3 +
frontend/src/locales/zhCN.json | 3 +
frontend/src/store/index.js | 29 +-
frontend/src/store/notifications.test.js | 107 +++++++
frontend/src/utils/defaultShortcuts.js | 8 +
frontend/src/utils/defaultShortcuts.test.js | 23 +-
frontend/src/utils/gtd.js | 21 +-
frontend/src/utils/gtd.test.js | 64 +++-
frontend/src/utils/inboxTriageUndo.js | 81 +++++
frontend/src/utils/inboxTriageUndo.test.js | 295 ++++++++++++++++++
frontend/src/utils/undoableArchive.js | 73 +++++
frontend/src/utils/undoableArchive.test.js | 192 ++++++++++++
22 files changed, 1141 insertions(+), 147 deletions(-)
create mode 100644 frontend/src/store/notifications.test.js
create mode 100644 frontend/src/utils/inboxTriageUndo.js
create mode 100644 frontend/src/utils/inboxTriageUndo.test.js
create mode 100644 frontend/src/utils/undoableArchive.js
create mode 100644 frontend/src/utils/undoableArchive.test.js
diff --git a/backend/src/routes/gtd.classify.test.js b/backend/src/routes/gtd.classify.test.js
index 38a6ea86..dee22176 100644
--- a/backend/src/routes/gtd.classify.test.js
+++ b/backend/src/routes/gtd.classify.test.js
@@ -39,9 +39,9 @@ const inboxMsg = { id: MSG_ID, account_id: ACCT_ID, uid: 10, folder: 'INBOX', me
const account = { id: ACCT_ID, user_id: 'u1', folder_mappings: {} };
// Route every query classify issues: the ownership-scoped message load, the account fetch
-// (POST copy path), and resolveCopyUid's sibling lookup (DELETE). Each is individually swappable
+// (POST copy path), and resolveCopyUid's sibling lookup. Each is individually swappable
// so a test can drive the not-owned (msg:null) / no-sibling (sibling:null) branches.
-function stubQueries({ msg = inboxMsg, acct = account, sibling = { uid: 42 } } = {}) {
+function stubQueries({ msg = inboxMsg, acct = account, sibling = null } = {}) {
query.mockImplementation(async (sql) => {
if (sql.includes('FROM messages m') && sql.includes('JOIN email_accounts')) return { rows: msg ? [msg] : [] };
if (sql.startsWith('SELECT * FROM email_accounts')) return { rows: acct ? [acct] : [] };
@@ -81,6 +81,7 @@ beforeEach(() => {
Object.values(imapManager).forEach(fn => fn.mockReset());
getGtdConfig.mockReset();
getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_GTD_FOLDERS });
+ imapManager.copyMessage.mockResolvedValue(42);
stubQueries();
});
@@ -101,24 +102,61 @@ describe('POST /api/gtd/classify — request validation', () => {
});
describe('POST /api/gtd/classify — apply a GTD label (COPY)', () => {
- it('copies an INBOX message into the state folder and echoes { ok, folder }', async () => {
+ it('copies an INBOX message into the state folder and reports an undoable apply', async () => {
const res = await classify({ messageId: MSG_ID, state: 'todo' });
expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ ok: true, folder: 'Todo' });
+ expect(await res.json()).toEqual({ ok: true, folder: 'Todo', applied: true, undoable: true });
// Callers own folder existence, so classify ensures then copies — the message stays in INBOX.
expect(imapManager.ensureFolder).toHaveBeenCalledWith(account, 'Todo');
expect(imapManager.copyMessage).toHaveBeenCalledWith(ACCT_ID, 10, 'INBOX', 'Todo');
});
- it('short-circuits when the message already lives in the state folder (no IMAP work)', async () => {
+ it('short-circuits when a sibling already lives in the state folder (no IMAP work)', async () => {
+ stubQueries({ sibling: { uid: 42 } });
+ const res = await classify({ messageId: MSG_ID, state: 'todo' });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true, folder: 'Todo', applied: false });
+ expect(imapManager.ensureFolder).not.toHaveBeenCalled();
+ expect(imapManager.copyMessage).not.toHaveBeenCalled();
+ });
+
+ it('short-circuits when the acted row already lives in the state folder (no IMAP work)', async () => {
stubQueries({ msg: { ...inboxMsg, folder: 'Todo' } });
const res = await classify({ messageId: MSG_ID, state: 'todo' });
expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ ok: true, folder: 'Todo' });
+ expect(await res.json()).toEqual({ ok: true, folder: 'Todo', applied: false });
expect(imapManager.ensureFolder).not.toHaveBeenCalled();
expect(imapManager.copyMessage).not.toHaveBeenCalled();
});
+ it('copies a message without a Message-ID but does not offer undo', async () => {
+ stubQueries({ msg: { ...inboxMsg, message_id: null } });
+ const res = await classify({ messageId: MSG_ID, state: 'todo' });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true, folder: 'Todo', applied: true, undoable: false });
+ expect(imapManager.copyMessage).toHaveBeenCalledWith(ACCT_ID, 10, 'INBOX', 'Todo');
+ });
+
+ it('does not offer immediate undo when a non-UIDPLUS copy has no confirmed destination identity', async () => {
+ imapManager.copyMessage.mockResolvedValue(null);
+
+ const applyRes = await classify({ messageId: MSG_ID, state: 'todo' });
+ expect(applyRes.status).toBe(200);
+ expect(await applyRes.json()).toEqual({
+ ok: true,
+ folder: 'Todo',
+ applied: true,
+ undoable: false,
+ });
+
+ // The deferred destination sync has not populated a sibling yet. This is
+ // exactly why classify must not advertise an immediately runnable inverse.
+ const undoRes = await unclassify({ messageId: MSG_ID, state: 'todo' });
+ expect(undoRes.status).toBe(200);
+ expect(await undoRes.json()).toEqual({ ok: true, removed: false });
+ expect(imapManager.removeMessageCopy).not.toHaveBeenCalled();
+ });
+
it("404s a message the caller doesn't own (the email_accounts join returns nothing)", async () => {
stubQueries({ msg: null });
const res = await classify({ messageId: MSG_ID, state: 'todo' });
@@ -137,6 +175,7 @@ describe('POST /api/gtd/classify — apply a GTD label (COPY)', () => {
describe('DELETE /api/gtd/classify — remove a GTD label', () => {
it('removes the sibling copy in the state folder and returns removed:true', async () => {
+ stubQueries({ sibling: { uid: 42 } });
const res = await unclassify({ messageId: MSG_ID, state: 'todo' });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, removed: true, folder: 'Todo' });
@@ -179,6 +218,7 @@ describe('DELETE /api/gtd/classify — remove a GTD label', () => {
});
it('maps an IMAP delete failure to 500', async () => {
+ stubQueries({ sibling: { uid: 42 } });
imapManager.removeMessageCopy.mockRejectedValue(new Error('IMAP delete failed'));
const res = await unclassify({ messageId: MSG_ID, state: 'todo' });
expect(res.status).toBe(500);
diff --git a/backend/src/routes/gtd.js b/backend/src/routes/gtd.js
index 39fdedd2..aa281eb5 100644
--- a/backend/src/routes/gtd.js
+++ b/backend/src/routes/gtd.js
@@ -170,21 +170,33 @@ router.post('/classify', async (req, res) => {
if (target.error) return res.status(target.status).json({ error: target.error });
const toFolder = target.folder;
- // Already labelled with this state — nothing to copy.
- if (msg.folder === toFolder) return res.json({ ok: true, folder: toFolder });
+ // Already labelled with this state — either the acted row is in the target
+ // folder or a COPY sibling shares its Message-ID. Never make a duplicate copy.
+ const existingUid = msg.folder === toFolder
+ ? msg.uid
+ : (msg.message_id ? await resolveCopyUid(msg, toFolder) : null);
+ if (existingUid != null) return res.json({ ok: true, folder: toFolder, applied: false });
const accountResult = await query('SELECT * FROM email_accounts WHERE id = $1', [msg.account_id]);
const account = accountResult.rows[0];
try {
await imapManager.ensureFolder(account, toFolder);
- await imapManager.copyMessage(msg.account_id, msg.uid, msg.folder, toFolder);
+ const copiedUid = await imapManager.copyMessage(msg.account_id, msg.uid, msg.folder, toFolder);
+ // UIDPLUS gives us the exact destination identity and copyMessage inserts
+ // that sibling before returning. Without it, destination ingestion is
+ // deferred; advertising an immediate inverse would race that sync and can
+ // falsely report removed:false.
+ return res.json({
+ ok: true,
+ folder: toFolder,
+ applied: true,
+ undoable: Boolean(msg.message_id && copiedUid != null),
+ });
} catch (err) {
console.error(`GTD classify failed for message ${messageId} -> ${toFolder}:`, err.message);
return res.status(500).json({ error: 'Failed to apply GTD label' });
}
-
- res.json({ ok: true, folder: toFolder });
});
// Resolve the folder-copy uid a message has in `folder` for this account, or null. The
diff --git a/frontend/src/components/MailApp.jsx b/frontend/src/components/MailApp.jsx
index 8e2df0e8..ca4c106d 100644
--- a/frontend/src/components/MailApp.jsx
+++ b/frontend/src/components/MailApp.jsx
@@ -8,7 +8,7 @@ import { LAYOUTS } from '../layouts.js';
import { updateFaviconBadge } from '../themes.js';
import { shortcutBus } from '../utils/shortcutBus.js';
import { setPending, pendingMarkReadMap, completedMarkReadMap } from '../utils/pendingReads.js';
-import { buildKeyMap, buildModKeyMap, getEffectiveShortcuts, getGroupedActions, parseModKey, modLabel, SPECIAL_KEYS, SPECIAL_KEY_LABELS } from '../utils/defaultShortcuts.js';
+import { buildKeyMap, buildModKeyMap, getEffectiveShortcuts, getGroupedActions, parseModKey, modLabel, shouldIgnoreGlobalShortcut, SPECIAL_KEYS, SPECIAL_KEY_LABELS } from '../utils/defaultShortcuts.js';
import Sidebar from './Sidebar.jsx';
import MessageList from './MessageList.jsx';
import MessagePane from './MessagePane.jsx';
@@ -563,10 +563,12 @@ export default function MailApp() {
};
const handler = (e) => {
- // Never intercept when the compose modal or admin panel is open, or an input is focused
- if (composingRef.current || showAdminRef.current) return;
- const tag = e.target.tagName;
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) return;
+ // Never intercept when the compose modal or admin panel is open, or an input is focused.
+ if (shouldIgnoreGlobalShortcut({
+ composing: composingRef.current,
+ showAdmin: showAdminRef.current,
+ target: e.target,
+ })) return;
// Modifier combos: emit registered actions, pass everything else through
if (e.ctrlKey || e.metaKey) {
const action = modKeyMap[e.key.toLowerCase()];
diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx
index 43ebe780..88eb5db0 100644
--- a/frontend/src/components/MessageList.jsx
+++ b/frontend/src/components/MessageList.jsx
@@ -22,6 +22,15 @@ import { shortcutBus } from '../utils/shortcutBus.js';
import { createLatestRequest } from '../utils/latestRequest.js';
import { pendingMarkReadMap, completedMarkReadMap, setPending } from '../utils/pendingReads.js';
import { applyDeleteGuard, clearDeleteGuard, clearPendingDelete, setCompletedDelete, setPendingDelete } from '../utils/pendingDeletes.js';
+import {
+ createInboxTriageUndoMetadata,
+ getUndoRemainingMs,
+ registerInboxTriageUndoShortcut,
+ scheduleInboxTriageUndoCommit,
+} from '../utils/inboxTriageUndo.js';
+import { scheduleUndoableArchive } from '../utils/undoableArchive.js';
+
+const EXIT_ANIMATION_MS = 190;
// Folder icon for move picker
function FolderIcon({ specialUse, size = 13 }) {
@@ -268,6 +277,16 @@ export default function MessageList() {
// Updated synchronously on every render so handlers are never stale.
const scRef = useRef({});
scRef.current = { messages, selectedIds, setSelectedIds, updateMessage, decrementUnread, addNotification };
+ const getInboxUndoMetadata = useCallback(() => {
+ const state = useStore.getState();
+ return createInboxTriageUndoMetadata({
+ selectedFolder: state.selectedFolder,
+ searchQuery: state.searchQuery,
+ activeGtdTab: state.activeGtdTab,
+ selectedMessageId: state.selectedMessageId,
+ displayedMessageIds: (scRef.current.displayMessages || []).map(message => message.id),
+ });
+ }, []);
const tRef = useRef(t);
useEffect(() => { tRef.current = t; }, [t]);
@@ -895,6 +914,7 @@ export default function MessageList() {
const isThreadRow = isThreadListRow(message);
const key = isThreadRow ? `thread:${tid}` : message.id;
if (pendingDeleteTimers.current.has(key)) return;
+ const undoMetadata = getInboxUndoMetadata();
let deleteMessages = [message];
try {
@@ -927,7 +947,7 @@ export default function MessageList() {
: deleteMessages.filter(msg => !msg.is_read).length;
if (unreadDelta > 0) decrementUnread(message.account_id, unreadDelta);
- const timer = setTimeout(async () => {
+ const { timer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
pendingDeleteTimers.current.delete(key);
try {
if (ids.length > 1) {
@@ -960,11 +980,12 @@ export default function MessageList() {
body: ids.length > 1 ? t('messageList.bulkDeleted.failBody', { count: ids.length }) : t('messageList.deleted.failBody'),
});
}
- }, 4500);
+ }, undoMetadata);
pendingDeleteTimers.current.set(key, { timer, message: visibleMessage, ids });
addNotification({
title: ids.length > 1 ? t('messageList.bulkDeleted.title', { count: ids.length }) : t('messageList.deleted.title'),
body: ids.length > 1 ? t('messageList.bulkDeleted.body') : t('messageList.deleted.body'),
+ ...deadlineMetadata,
onUndo: () => {
const pending = pendingDeleteTimers.current.get(key);
if (!pending) return;
@@ -978,7 +999,7 @@ export default function MessageList() {
}, [
isThreadListRow, expandedThreadId, resolveMessagesForThreadAction,
removeMessage, setExpandedThreadId, decrementUnread, incrementUnread,
- addNotification, t,
+ addNotification, getInboxUndoMetadata, t,
]);
// Antispam helpers (v0.1).
@@ -997,6 +1018,7 @@ export default function MessageList() {
const performSpamLabel = useCallback(async (messages, label) => {
if (!messages.length) return;
+ const undoMetadata = getInboxUndoMetadata();
const ids = messages.map(m => m.id);
const isBulk = ids.length > 1;
@@ -1046,6 +1068,7 @@ export default function MessageList() {
const timers = new Map();
let settled = false;
const undo = () => {
+ if (settled) return;
settled = true;
timers.forEach(timer => clearTimeout(timer));
timers.clear();
@@ -1068,8 +1091,9 @@ export default function MessageList() {
return fn(id).catch(err => ({ __failed: true, id, message: err.message }));
};
- timers.set('__call__', setTimeout(async () => {
+ const { timer: callTimer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
if (settled) return;
+ settled = true;
timers.delete('__call__');
const results = await Promise.allSettled(ids.map(performCall));
const failed = [];
@@ -1096,16 +1120,18 @@ export default function MessageList() {
// concurrent IMAP IDLE update, can desync the local counters.
api.getUnreadCounts().then(c => useStore.getState().setUnreadCounts(c)).catch(() => {});
api.getFolders(accountId).then(f => useStore.getState().setFolders(accountId, f)).catch(() => {});
- }, 4500));
+ }, undoMetadata);
+ timers.set('__call__', callTimer);
addNotification({
title: label === 'spam'
? (isBulk ? t('spam.movedToSpamBulk', { count: ids.length }) : t('spam.movedToSpam'))
: (isBulk ? t('spam.movedToInboxBulk', { count: ids.length }) : t('spam.movedToInbox')),
body: messages[0].subject || t('common.noSubject'),
+ ...deadlineMetadata,
onUndo: undo,
});
- }, [removeMessage, decrementUnread, incrementUnread, addNotification, t, accounts]);
+ }, [removeMessage, decrementUnread, incrementUnread, addNotification, getInboxUndoMetadata, t, accounts]);
// On page unload (refresh/close), fire pending deletes with keepalive:true so the
// browser completes the request even after the page tears down. Clears the map so
@@ -1178,31 +1204,42 @@ export default function MessageList() {
await setMessagesReadState(message, isUnread);
}, [setMessagesReadState]);
- const handleSwipeArchive = useCallback(async (message) => {
- refreshRequestRef.current.invalidate();
- advanceSelectionAfterRemoval(message.id);
- removeMessage(message.id);
- if (!message.is_read) decrementUnread(message.account_id);
- let undone = false;
- const timer = setTimeout(async () => {
- if (undone) return;
- try {
- await api.bulkArchive([message.id]);
- } catch (err) {
- console.error('swipe archive failed:', err.message);
- }
- }, 4500);
- addNotification({
- title: t('messageList.bulkArchived.title', { count: 1 }),
- body: message.subject || '',
- onUndo: () => {
- undone = true;
- clearTimeout(timer);
- useStore.getState().restoreMessages([message]);
- if (!message.is_read) incrementUnread(message.account_id);
+ const scheduleArchive = useCallback((message) => {
+ const state = useStore.getState();
+ const undoMetadata = getInboxUndoMetadata();
+
+ scheduleUndoableArchive(message, {
+ archive: id => api.bulkArchive([id]),
+ invalidate: () => refreshRequestRef.current.invalidate(),
+ markPending: setPendingDelete,
+ advanceSelection: advanceSelectionAfterRemoval,
+ removeMessage: state.removeMessage,
+ restoreMessage: restored => useStore.getState().restoreMessages([restored]),
+ decrementUnread: state.decrementUnread,
+ incrementUnread: state.incrementUnread,
+ clearPending: clearPendingDelete,
+ clearGuard: clearDeleteGuard,
+ completeGuard: setCompletedDelete,
+ addNotification: state.addNotification,
+ notifyNoFolder: () => state.addNotification({
+ title: tRef.current('messageList.noArchiveFolder.title'),
+ body: tRef.current('messageList.noArchiveFolder.body'),
+ }),
+ notifyFailure: () => state.addNotification({
+ title: tRef.current('message.archived.failTitle'),
+ body: tRef.current('message.archived.failBody'),
+ }),
+ notification: {
+ title: tRef.current('message.archived.title'),
+ body: message.subject || tRef.current('common.noSubject'),
},
+ undoMetadata,
});
- }, [removeMessage, decrementUnread, incrementUnread, addNotification, t]);
+ }, [getInboxUndoMetadata]);
+
+ const handleSwipeArchive = useCallback((message) => {
+ scheduleArchive(message);
+ }, [scheduleArchive]);
const handleSwipeStar = useCallback((message) => {
setMessagesStarredState(message, !message.is_starred);
@@ -1396,6 +1433,7 @@ export default function MessageList() {
}, [displayMessages]);
const handleBulkDelete = useCallback(async (ids, msgs) => {
+ const undoMetadata = getInboxUndoMetadata();
const key = `bulk:${ids[0]}`;
// Selected thread rows delete the whole conversation, matching the
// single-row delete path — without this only each thread's visible
@@ -1422,9 +1460,10 @@ export default function MessageList() {
setSelectionModeActive(false);
setShowFolderPicker(false);
let undone = false;
- const timer = setTimeout(async () => {
+ const { timer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
pendingDeleteTimers.current.delete(key);
if (undone) return;
+ undone = true;
const chunks = [];
for (let i = 0; i < deleteIds.length; i += 500) chunks.push(deleteIds.slice(i, i + 500));
const results = await Promise.allSettled(chunks.map(chunk => api.bulkDelete(chunk)));
@@ -1450,12 +1489,14 @@ export default function MessageList() {
if (useStore.getState().searchQuery.trim()) {
setSearchReloadToken(token => token + 1);
}
- }, 4500);
+ }, undoMetadata);
pendingDeleteTimers.current.set(key, { timer, message: msgs[0], ids: deleteIds });
addNotification({
title: t('messageList.bulkDeleted.title', { count: deleteIds.length }),
body: t('messageList.bulkDeleted.body'),
+ ...deadlineMetadata,
onUndo: () => {
+ if (undone) return;
undone = true;
clearTimeout(timer);
pendingDeleteTimers.current.delete(key);
@@ -1467,9 +1508,10 @@ export default function MessageList() {
});
},
});
- }, [searchHasMore, removeMessage, prefetchSearchAfterRemoval, resolveMessagesForThreadAction, decrementUnread, incrementUnread, addNotification, t]);
+ }, [searchHasMore, removeMessage, prefetchSearchAfterRemoval, resolveMessagesForThreadAction, decrementUnread, incrementUnread, addNotification, getInboxUndoMetadata, t]);
const handleBulkMove = useCallback(async (ids, msgs, folder) => {
+ const undoMetadata = getInboxUndoMetadata();
// Selected thread rows move the whole conversation. A folder path is
// account-specific, so scope each thread's expansion to its row's own
// account — the server would just skip (and previously silently drop)
@@ -1490,8 +1532,9 @@ export default function MessageList() {
setSelectionModeActive(false);
setShowFolderPicker(false);
let undone = false;
- const timer = setTimeout(async () => {
+ const { timer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
if (undone) return;
+ undone = true;
try {
const result = await api.bulkMove(moveIds, folder);
const movedSet = new Set(result.moved ?? []);
@@ -1508,18 +1551,20 @@ export default function MessageList() {
useStore.getState().restoreMessages(msgs);
addNotification({ title: t('messageList.bulkMoved.failTitle'), body: t('messageList.bulkMoved.failBody', { count: moveIds.length }) });
}
- }, 4500);
+ }, undoMetadata);
addNotification({
title: t('messageList.bulkMoved.title', { count: moveIds.length }),
body: folder,
+ ...deadlineMetadata,
onUndo: () => {
+ if (undone) return;
undone = true;
clearTimeout(timer);
useStore.getState().restoreMessages(msgs);
msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); });
},
});
- }, [removeMessage, decrementUnread, incrementUnread, resolveMessagesForThreadAction, addNotification, t]);
+ }, [removeMessage, decrementUnread, incrementUnread, resolveMessagesForThreadAction, addNotification, getInboxUndoMetadata, t]);
const handleRowMove = useCallback((e, msg) => {
e.stopPropagation();
@@ -1538,6 +1583,7 @@ export default function MessageList() {
}, []);
const handleBulkArchive = useCallback((ids, msgs) => {
+ const undoMetadata = getInboxUndoMetadata();
refreshRequestRef.current.invalidate();
// Tombstone the ids so a background refresh/websocket refetch during the undo window
// can't resurrect them — applyDeleteGuard filters pending/completed ids out of refetch
@@ -1549,8 +1595,9 @@ export default function MessageList() {
setSelectionModeActive(false);
setShowFolderPicker(false);
let undone = false;
- const timer = setTimeout(async () => {
+ const { timer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
if (undone) return;
+ undone = true;
try {
const result = await api.bulkArchive(ids);
const archivedSet = new Set(result.archived ?? []);
@@ -1574,11 +1621,13 @@ export default function MessageList() {
msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); });
addNotification({ title: t('messageList.bulkArchived.failTitle'), body: t('messageList.bulkArchived.failBody', { count: ids.length }) });
}
- }, 4500);
+ }, undoMetadata);
addNotification({
title: t('messageList.bulkArchived.title', { count: ids.length }),
body: t('messageList.bulkArchived.body'),
+ ...deadlineMetadata,
onUndo: () => {
+ if (undone) return;
undone = true;
clearTimeout(timer);
ids.forEach(id => clearPendingDelete(id));
@@ -1586,7 +1635,7 @@ export default function MessageList() {
msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); });
},
});
- }, [removeMessages, decrementUnread, incrementUnread, addNotification, t]);
+ }, [removeMessages, decrementUnread, incrementUnread, addNotification, getInboxUndoMetadata, t]);
const handleBulkMarkRead = useCallback(async (ids, msgs) => {
const markAsRead = msgs.some(m => !m.is_read);
@@ -1630,10 +1679,12 @@ export default function MessageList() {
// Keep refs to bulk handlers so the shortcut effect (registered once) is never stale
const bulkDeleteRef = useRef(handleBulkDelete);
const bulkArchiveRef = useRef(handleBulkArchive);
+ const scheduleArchiveRef = useRef(scheduleArchive);
const scheduleDeleteRef = useRef(scheduleDelete);
const handleContextActionRef = useRef(null); // assigned below, once handleContextAction is defined
useEffect(() => { bulkDeleteRef.current = handleBulkDelete; }, [handleBulkDelete]);
useEffect(() => { bulkArchiveRef.current = handleBulkArchive; }, [handleBulkArchive]);
+ useEffect(() => { scheduleArchiveRef.current = scheduleArchive; }, [scheduleArchive]);
useEffect(() => { scheduleDeleteRef.current = scheduleDelete; }, [scheduleDelete]);
// Subscribe to keyboard shortcut actions that belong to the message list.
@@ -1711,26 +1762,15 @@ export default function MessageList() {
};
const onArchive = () => {
- const { messages, searchResults, searchQuery, selectedMessageId, removeMessage, decrementUnread, addNotification } = getState();
+ const { messages, searchResults, searchQuery, selectedMessageId } = getState();
const pool = searchQuery.trim() ? searchResults : messages;
const ids = [...scRef.current.selectedIds];
if (ids.length > 0) {
const msgs = pool.filter(m => ids.includes(m.id));
bulkArchiveRef.current(ids, msgs);
} else if (selectedMessageId) {
- const msg = pool.find(m => m.id === selectedMessageId);
- if (!msg) return;
- refreshRequestRef.current.invalidate();
- setPendingDelete(selectedMessageId);
- advanceSelectionAfterRemoval(selectedMessageId);
- removeMessage(selectedMessageId);
- if (!msg.is_read) decrementUnread(msg.account_id);
- api.bulkArchive([selectedMessageId]).then(result => {
- setCompletedDelete(selectedMessageId);
- if (result.noArchiveFolder?.length) {
- addNotification({ title: tRef.current('messageList.noArchiveFolder.title'), body: tRef.current('messageList.noArchiveFolder.body') });
- }
- }).catch(err => { clearDeleteGuard(selectedMessageId); console.error(err); });
+ const msg = pool.find(item => item.id === selectedMessageId);
+ if (msg) scheduleArchiveRef.current(msg);
}
};
@@ -1799,6 +1839,11 @@ export default function MessageList() {
const onGtdTodo = gtdClassifySelected('todo');
const onGtdWatch = gtdClassifySelected('watch');
const onGtdDelegated = gtdClassifySelected('delegated');
+ const cleanupUndo = registerInboxTriageUndoShortcut({
+ shortcutBus,
+ getState,
+ getDisplayedMessageIds: () => (scRef.current.displayMessages || []).map(message => message.id),
+ });
shortcutBus.on('nextMessage', onNext);
shortcutBus.on('prevMessage', onPrev);
@@ -1819,6 +1864,7 @@ export default function MessageList() {
shortcutBus.off('selectMessage', onSelect);
shortcutBus.off('archive', onArchive);
shortcutBus.off('delete', onDelete);
+ cleanupUndo();
shortcutBus.off('toggleRead', onToggleRead);
shortcutBus.off('focusSearch', onFocusSearch);
shortcutBus.off('gtdTodo', onGtdTodo);
@@ -1897,39 +1943,13 @@ export default function MessageList() {
setSelectedIds(new Set([message.id]));
break;
case 'archive': {
- const archived = message;
- refreshRequestRef.current.invalidate();
- advanceSelectionAfterRemoval(archived.id);
- removeMessage(archived.id);
- if (!archived.is_read) decrementUnread(archived.account_id);
- let archiveUndone = false;
- const archiveTimer = setTimeout(async () => {
- if (archiveUndone) return;
- try {
- const result = await api.bulkArchive([archived.id]);
- if (result.noArchiveFolder?.length) {
- addNotification({ title: t('message.archived.noFolderTitle'), body: t('message.archived.noFolderBody') });
- }
- } catch (err) {
- console.error('Archive failed:', err.message);
- addNotification({ title: t('message.archived.failTitle'), body: t('message.archived.failBody') });
- }
- }, 4500);
- addNotification({
- title: t('message.archived.title'),
- body: archived.subject || t('common.noSubject'),
- onUndo: () => {
- archiveUndone = true;
- clearTimeout(archiveTimer);
- useStore.getState().restoreMessages([archived]);
- if (!archived.is_read) incrementUnread(archived.account_id);
- },
- });
+ scheduleArchive(message);
break;
}
case 'moveTo': {
const folder = data;
if (!folder) break;
+ const undoMetadata = getInboxUndoMetadata();
// If multiple messages are checked and the right-clicked message is among them,
// delegate to handleBulkMove so all selected messages are moved together.
if (selectedIds.size > 1 && selectedIds.has(message.id)) {
@@ -1965,8 +1985,9 @@ export default function MessageList() {
if (next.size === 0) setSelectionModeActive(false);
}
let moveUndone = false;
- const moveTimer = setTimeout(async () => {
+ const { timer: moveTimer, undoMetadata: deadlineMetadata } = scheduleInboxTriageUndoCommit(async () => {
if (moveUndone) return;
+ moveUndone = true;
try {
const result = await api.bulkMove(moveIds, folder);
// The server reports per-message success (200 even when some IMAP
@@ -1989,11 +2010,13 @@ export default function MessageList() {
if (!moved.is_read) incrementUnread(moved.account_id);
addNotification({ title: t('message.moved.failTitle'), body: t('message.moved.failBody') });
}
- }, 4500);
+ }, undoMetadata);
addNotification({
title: t('message.moved.title'),
body: folder,
+ ...deadlineMetadata,
onUndo: () => {
+ if (moveUndone) return;
moveUndone = true;
clearTimeout(moveTimer);
useStore.getState().restoreMessages([moved]);
@@ -2024,14 +2047,23 @@ export default function MessageList() {
break;
}
case 'gtdClassify': {
- // Classify = COPY into the state's label folder. The message stays put
- // (no optimistic removal / undo — it does not leave INBOX), so we just
- // fire the copy and poke the GTD sections store instead of waiting on the WS event.
+ // Snapshot eligibility before the request, because the delayed response may
+ // arrive after navigation or a search/GTD-context change.
+ const inboxUndo = getInboxUndoMetadata();
await classifyThread(message.id, data, {
gtdClassify: api.gtdClassify,
addNotification,
scheduleGtdSectionsFetch: useStore.getState().scheduleGtdSectionsFetch,
t,
+ createUndo: () => inboxUndo.undoScope ? {
+ ...inboxUndo,
+ onUndo: () => unclassifyThread(message.id, data, {
+ gtdUnclassify: api.gtdUnclassify,
+ addNotification,
+ scheduleGtdSectionsFetch: useStore.getState().scheduleGtdSectionsFetch,
+ t,
+ }),
+ } : null,
});
break;
}
@@ -3722,22 +3754,34 @@ export default function MessageList() {
function UndoBar({ notification, onDismiss, showTopBorder }) {
const { t } = useTranslation();
+ const runNotificationUndo = useStore(state => state.runNotificationUndo);
const [exiting, setExiting] = useState(false);
+ const remainingMs = getUndoRemainingMs(notification);
const dismiss = () => {
+ if (getUndoRemainingMs(notification) <= EXIT_ANIMATION_MS) {
+ onDismiss();
+ return;
+ }
setExiting(true);
- setTimeout(onDismiss, 190);
+ setTimeout(onDismiss, EXIT_ANIMATION_MS);
};
const handleUndo = () => {
- notification.onUndo();
- dismiss();
+ runNotificationUndo(notification.id);
};
useEffect(() => {
- const timer = setTimeout(dismiss, 6000);
+ const remaining = getUndoRemainingMs(notification);
+ if (remaining <= 0) {
+ onDismiss();
+ return undefined;
+ }
+ const timer = setTimeout(onDismiss, remaining);
return () => clearTimeout(timer);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [notification, onDismiss]);
+
+ if (remainingMs <= 0) return null;
return (
state.runNotificationUndo);
const [exiting, setExiting] = useState(false);
+ const remainingMs = getUndoRemainingMs(notification);
const dismiss = () => {
+ if (getUndoRemainingMs(notification) <= EXIT_ANIMATION_MS) {
+ onDismiss();
+ return;
+ }
setExiting(true);
- setTimeout(onDismiss, 190);
+ setTimeout(onDismiss, EXIT_ANIMATION_MS);
};
const handleUndo = () => {
- notification.onUndo();
- dismiss();
+ runNotificationUndo(notification.id);
};
useEffect(() => {
- const timer = setTimeout(dismiss, 6000);
+ const remaining = getUndoRemainingMs(notification);
+ if (remaining <= 0) {
+ onDismiss();
+ return undefined;
+ }
+ const timer = setTimeout(onDismiss, remaining);
return () => clearTimeout(timer);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [notification, onDismiss]);
+
+ if (remainingMs <= 0) return null;
return (
- {/* Progress bar — empties over 4.5s (the undo window) */}
+ {/* Progress bar — empties at the notification's absolute undo expiry. */}
state.runNotificationUndo);
const [exiting, setExiting] = useState(false);
+ const isUndoable = typeof notification.onUndo === 'function';
+ const remainingMs = isUndoable ? getUndoRemainingMs(notification) : 5000;
- const dismiss = () => {
+ const dismiss = useCallback(() => {
+ if (isUndoable && getUndoRemainingMs(notification) <= EXIT_ANIMATION_MS) {
+ onDismiss();
+ return;
+ }
setExiting(true);
- setTimeout(onDismiss, 190);
- };
+ setTimeout(onDismiss, EXIT_ANIMATION_MS);
+ }, [isUndoable, notification, onDismiss]);
useEffect(() => {
- if (notification.persistent) return undefined;
-
- const duration = notification.onUndo ? 6000 : 5000;
- const timer = setTimeout(dismiss, duration);
+ if (notification.persistent && !isUndoable) return undefined;
+ const remaining = isUndoable ? getUndoRemainingMs(notification) : 5000;
+ if (remaining <= 0) {
+ onDismiss();
+ return undefined;
+ }
+ const timer = setTimeout(isUndoable ? onDismiss : dismiss, remaining);
return () => clearTimeout(timer);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [dismiss, isUndoable, notification, onDismiss]);
const handleUndo = () => {
- notification.onUndo();
- dismiss();
+ runNotificationUndo(notification.id);
};
const enterClass = isMobile ? 'toast-enter-mobile' : 'toast-enter';
@@ -225,7 +249,7 @@ function Toast({ notification, onDismiss, isMobile }) {
{notification.actionLabel || t('common.view')}
)}
- {notification.onUndo && (
+ {isUndoable && remainingMs > 0 && (