feat: offer undo after marking read or unread, starring or tagging from the list

This commit is contained in:
Linus Rath
2026-10-31 04:21:00 +01:00
parent e09cd395a2
commit da1da0ec6b
2 changed files with 120 additions and 1 deletions
+38
View File
@@ -1566,6 +1566,44 @@ describe('email-store', () => {
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
});
// Read, star and tag changes from the list had no Undo (webmail
// ee17c0dba, lib/email-action-toast.ts): now each message gets back the
// keyword it had, and nothing else moves.
it('offers an undo for a read, star or tag change that puts back only that keyword', async () => {
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType<typeof vi.fn>;
useEmailStore.setState({
mailboxes: [inbox, junk],
currentMailboxId: 'mb-1',
emails: [
{ id: 'e1', keywords: {}, mailboxIds: { 'mb-1': true } } as any,
{ id: 'e2', keywords: { $seen: true }, mailboxIds: { 'mb-1': true } } as any,
],
});
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$seen', true);
const undo = useEmailStore.getState().pendingUndo!;
expect(undo.kind).toBe('keywords');
expect(undo.label).toContain('marked as read');
await useEmailStore.getState().undoLast();
expect(mockRestore).not.toHaveBeenCalled();
expect(mockPatchPerEmail).toHaveBeenCalledWith(
[{ id: 'e1', patch: { $seen: null } }, { id: 'e2', patch: { $seen: true } }],
undefined,
);
const byId = Object.fromEntries(useEmailStore.getState().emails.map((e) => [e.id, e.keywords]));
expect(byId.e1.$seen).toBeUndefined();
expect(byId.e2.$seen).toBe(true);
// Tags and stars too; the reader's own toggles get no bar.
await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:work', true);
expect(useEmailStore.getState().pendingUndo?.kind).toBe('keywords');
useEmailStore.getState().clearUndo();
await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true, { email: useEmailStore.getState().emails[0] });
expect(useEmailStore.getState().pendingUndo).toBeNull();
});
it('unmarkSpam moves back to Inbox with $notjunk', async () => {
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
useEmailStore.setState({
+82 -1
View File
@@ -315,11 +315,34 @@ function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailbo
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
}
/**
* The undo bar's label after a read, star or tag change from the list (webmail
* lib/email-action-toast.ts, ee17c0dba). Null for other keywords, which get
* no Undo.
*/
export function keywordUndoLabel(token: string, on: boolean, count: number): string | null {
if (token === '$seen') {
return on
? t('notifications.emails_marked_read', '{count, plural, one {# email marked as read} other {# emails marked as read}}', { count })
: t('notifications.emails_marked_unread', '{count, plural, one {# email marked as unread} other {# emails marked as unread}}', { count });
}
if (token === '$flagged') {
return on ? t('notifications.email_starred', 'Email starred') : t('notifications.email_unstarred', 'Email unstarred');
}
if (token.startsWith('$label:')) {
return on
? t('notifications.email_tagged', 'Email tagged')
: t('notifications.tag_removed_count', '{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}', { count });
}
return null;
}
// Snapshot of an action that can still be reversed via the undo snackbar.
// We store the full email object so undo can re-insert it into the visible list
// optimistically without waiting for a refetch.
export interface UndoEntry {
kind: 'archive' | 'delete' | 'move' | 'spam';
/** `keywords`: a read, star or tag change; undo puts back only `keywordPatch`'s keywords. */
kind: 'archive' | 'delete' | 'move' | 'spam' | 'keywords';
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
@@ -2106,6 +2129,26 @@ export const useEmailStore = create<EmailState>()(
for (const { accountId, emails } of written) {
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
}
// Undo for a read, star or tag change from the list: each message gets
// back the keyword it had (the reader's own toggles get no bar).
const changed = written.flatMap(({ accountId, emails }) => emails.map((email) => ({ email, accountId })));
const label = !viewed && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null;
if (label) {
set({
pendingUndo: {
kind: 'keywords',
label,
createdAt: Date.now(),
keywordPatch: patch,
items: changed.map(({ email, accountId }) => ({
email,
originalMailboxIds: { ...email.mailboxIds },
originalKeywords: { ...email.keywords },
accountId,
})),
},
});
}
if (refusal) throw refusal;
},
@@ -2114,6 +2157,44 @@ export const useEmailStore = create<EmailState>()(
if (!entry) return;
set({ pendingUndo: null });
// A read, star or tag change: only those keywords go back; the messages
// stay in whatever folder they are in now.
if (entry.kind === 'keywords') {
const keywordPatch = entry.keywordPatch ?? {};
const undo = entry.items.map((it) => ({
id: it.email.id,
key: rowKeyOf(it.email),
accountId: it.accountId ?? entry.accountId,
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
}));
const accounts = new Set(undo.map((u) => u.accountId ?? ''));
try {
await applyOrQueueBatch(
undo.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, accountId: u.accountId, patch: u.patch })),
async () => {
for (const account of accounts) {
await patchKeywordsPerEmail(
undo.filter((u) => (u.accountId ?? '') === account).map(({ id, patch }) => ({ id, patch })),
account || undefined,
);
}
},
);
} catch (err) {
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
return;
}
const byKey = new Map(undo.map((u) => [u.key, u.patch]));
set({
emails: get().emails.map((e) => {
const patch = byKey.get(rowKeyOf(e));
return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e;
}),
});
for (const u of undo) patchCache(u.id, { keywords: u.patch }, u.accountId);
return;
}
// An action on a list spanning accounts is undone in each item's account.
const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId;
const byAccount = new Map<string, { accountId?: string; items: UndoEntry['items'] }>();