diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 39c9f696..8578b40e 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -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; + const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType; + 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; useEmailStore.setState({ diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index d0149b6d..4b8ec214 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -315,11 +315,34 @@ function openRoleOf(state: Partial 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()( 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()( 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();