diff --git a/src/lib/tag-undo-group.ts b/src/lib/tag-undo-group.ts new file mode 100644 index 00000000..cbbe0fcb --- /dev/null +++ b/src/lib/tag-undo-group.ts @@ -0,0 +1,22 @@ +// One Undo for everything changed in a tag sheet. The sheet stays open so +// several tags can be changed in a row (webmail setSelectionTagWithUndo), and +// it covers the undo bar while it is up: the changes join one Undo, and the +// bar shows for its full time once the sheet closes. + +import React from 'react'; +import { useEmailStore } from '../stores/email-store'; + +let seq = 0; + +/** The undo group of a tag sheet while `open`; '' while it is closed. */ +export function useTagUndoGroup(open: boolean): string { + const group = React.useRef(''); + if (open && !group.current) group.current = `tags:${++seq}`; + React.useEffect(() => { + if (open || !group.current) return; + const done = group.current; + group.current = ''; + useEmailStore.getState().releaseUndoGroup(done); + }, [open]); + return open ? group.current : ''; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 58605026..dfe21dbf 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -22,6 +22,7 @@ import SenderAvatar from '../components/SenderAvatar'; import { SwipeableRow } from '../components/SwipeableRow'; import { MoveSheet } from '../components/MoveSheet'; import { TagSheet } from '../components/TagSheet'; +import { useTagUndoGroup } from '../lib/tag-undo-group'; import { OfflineBanner } from '../components/OfflineBanner'; import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink'; import { @@ -711,6 +712,8 @@ export default function EmailListScreen({ // Batch (multi-select) sheets. const [batchMoveOpen, setBatchMoveOpen] = React.useState(false); const [tagSheetOpen, setTagSheetOpen] = React.useState(false); + // The sheet's changes share one Undo, shown once it closes. + const tagUndoGroup = useTagUndoGroup(tagSheetOpen); // Selection state const [selectedIds, setSelectedIds] = React.useState>(new Set()); @@ -1030,7 +1033,7 @@ export default function EmailListScreen({ }; const handleBatchTagToggle = (token: string, on: boolean) => { - setKeywordForEmails(selectedMessageIds, token, on).catch((err) => + setKeywordForEmails(selectedMessageIds, token, on, undefined, { undoGroup: tagUndoGroup }).catch((err) => reportKeywordFailure(t('notifications.tag_failed', 'Tagging failed'), err)); }; diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 65eb18f5..5eee6048 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -15,6 +15,7 @@ import { } from '../components/icons'; import { pickEmailBody } from '../lib/email-body'; import { confirmMailRemoval } from '../lib/mail-removal-confirm'; +import { useTagUndoGroup } from '../lib/tag-undo-group'; import { NewTagRow } from '../components/NewTagRow'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { isSieveSupported } from '../api/sieve'; @@ -177,6 +178,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const [moreMenuOpen, setMoreMenuOpen] = React.useState(false); const [moveMenuOpen, setMoveMenuOpen] = React.useState(false); const [tagMenuOpen, setTagMenuOpen] = React.useState(false); + // Tag changes made in the sheet get one Undo, shown once it closes. + const tagUndoGroup = useTagUndoGroup(tagMenuOpen); const [rulesOpen, setRulesOpen] = React.useState(false); const [addressSheet, setAddressSheet] = React.useState(null); // Per-message override of the light/dark rendering (More sheet toggle). @@ -378,8 +381,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr // list row follows when the list holds that account (never a same-id row // of another one). const setKeyword = React.useCallback( - (target: Email, token: string, on: boolean) => - setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }), + (target: Email, token: string, on: boolean, undoGroup?: string) => + setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }, { undoGroup }), [setKeywordForEmails, ownerAccountId], ); @@ -473,7 +476,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr if (next[token]) delete next[token]; else next[token] = true; updateLocalKeywords(email.id, next); - setKeyword(email, token, !!next[token]).catch((err) => { + setKeyword(email, token, !!next[token], tagUndoGroup || undefined).catch((err) => { updateLocalKeywords(email.id, email.keywords); toastFailure(t('notifications.tag_failed', 'Tagging failed'), err); }); diff --git a/src/stores/__tests__/email-store-keyword-undo.test.ts b/src/stores/__tests__/email-store-keyword-undo.test.ts index eb6e8015..2b891b54 100644 --- a/src/stores/__tests__/email-store-keyword-undo.test.ts +++ b/src/stores/__tests__/email-store-keyword-undo.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; // Actions the viewer runs on the message it shows, which may live in another // account than the open folder (unified inbox, notification, deep link). @@ -129,6 +131,63 @@ beforeEach(() => { useEmailStore.getState().reset(); }); +describe('tag undo from a tag sheet', () => { + it('gathers the changes made in one sheet into one Undo that puts every tag back', async () => { + const tagged = { ...ROW, keywords: { $seen: true, '$label:blue': true } } as Email; + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [tagged] }); + const opts = { undoGroup: 'tags:1' }; + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, undefined, opts); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:blue', false, undefined, opts); + // The bar sat under the sheet; closing it shows the bar from now. + useEmailStore.setState({ pendingUndo: { ...useEmailStore.getState().pendingUndo!, createdAt: 1 } }); + useEmailStore.getState().releaseUndoGroup('tags:1'); + const entry = useEmailStore.getState().pendingUndo!; + expect(entry.undoGroup).toBeUndefined(); + expect(entry.createdAt).toBeGreaterThan(1); + + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).toHaveBeenCalledWith( + [{ id: 'e1', patch: { '$label:orange': null, '$label:blue': true } }], + undefined, + ); + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$label:blue': true }); + }); + + it('offers Undo for a tag set in the viewer\'s tag sheet, and none for its star', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [] }); + const viewed = { email: ROW, accountId: undefined }; + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true, viewed); + expect(useEmailStore.getState().pendingUndo).toBeNull(); + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, viewed, { undoGroup: 'tags:2' }); + expect(useEmailStore.getState().pendingUndo).toMatchObject({ kind: 'keywords', label: 'Email tagged' }); + + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).toHaveBeenCalledWith([{ id: 'e1', patch: { '$label:orange': null } }], undefined); + }); + + it('starts a new Undo for the next sheet', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] }); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, undefined, { undoGroup: 'tags:3' }); + useEmailStore.getState().releaseUndoGroup('tags:3'); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:green', true, undefined, { undoGroup: 'tags:4' }); + + expect(useEmailStore.getState().pendingUndo?.keywordPatch).toEqual({ '$label:green': true }); + }); + + it('wires both tag sheets to one Undo per sheet', () => { + const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + const list = read('screens/EmailListScreen.tsx'); + expect(list).toMatch(/const tagUndoGroup = useTagUndoGroup\(tagSheetOpen\);/); + expect(list).toMatch(/setKeywordForEmails\(selectedMessageIds, token, on, undefined, \{ undoGroup: tagUndoGroup \}\)/); + const viewer = read('screens/EmailThreadScreen.tsx'); + expect(viewer).toMatch(/const tagUndoGroup = useTagUndoGroup\(tagMenuOpen\);/); + expect(viewer).toMatch(/setKeyword\(email, token, !!next\[token\], tagUndoGroup \|\| undefined\)/); + }); +}); + describe('keyword undo', () => { it('undoes a star made after a tag', async () => { useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] }); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index f59d7c6b..a4176993 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -55,6 +55,7 @@ import { useSettingsStore } from './settings-store'; import { useOfflineCacheStore } from './offline-cache-store'; import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store'; import { useTagCountsStore } from './tag-counts-store'; +import { patchDetail, peekDetail } from '../lib/email-detail-cache'; // ── Refresh coalescing ───────────────────────────────────────────────── // Push events, mount effects and post-action follow-ups all call @@ -358,6 +359,11 @@ export interface UndoEntry { label: string; /** Time the entry was created - the snackbar uses this to drive its timer. */ createdAt: number; + /** + * The open tag sheet the changes came from: each further change made in it + * joins this entry, and closing it shows the bar afresh (releaseUndoGroup). + */ + undoGroup?: string; /** JMAP account the messages live under; unset for the user's own mail. */ accountId?: string; /** @@ -380,9 +386,46 @@ export interface UndoEntry { * (a list that spans accounts); `accountId` above applies otherwise. */ accountId?: string; + /** The viewer's message, not a row of the list: undo leaves the list alone. */ + unlisted?: boolean; }>; } +/** Options of setKeywordForEmails. */ +export interface KeywordChangeOptions { + /** + * The tag sheet the change is made in. Gives a viewer change an Undo too, + * and gathers every change made before the sheet closes into one Undo. + */ + undoGroup?: string; +} + +/** + * Adds a change made in the same tag sheet to its Undo: each message keeps + * the value a keyword had before the sheet first changed it. + */ +function joinKeywordUndo( + entry: UndoEntry, + items: UndoEntry['items'], + patch: KeywordPatch, +): Pick { + const before = entry.keywordPatch ?? {}; + const itemKey = (it: UndoEntry['items'][number]) => `${it.accountId ?? ''}|${rowKeyOf(it.email)}`; + const earlier = new Map(entry.items.map((it) => [itemKey(it), it])); + const joined = items.map((it) => { + const prev = earlier.get(itemKey(it)); + if (!prev) return it; + earlier.delete(itemKey(it)); + const originalKeywords = { ...it.originalKeywords }; + for (const keyword of Object.keys(before)) { + if (prev.originalKeywords?.[keyword]) originalKeywords[keyword] = true; + else delete originalKeywords[keyword]; + } + return { ...it, originalKeywords }; + }); + return { keywordPatch: { ...before, ...patch }, items: [...earlier.values(), ...joined] }; +} + // Cached emails for one mailbox (the base view: no search query, no filters). // `queryState` is the JMAP queryState for the matching Email/query, used to // drive Email/queryChanges on the next refresh. @@ -502,7 +545,14 @@ export interface EmailState { * `Email/set`. With `viewed`, on the viewer's message in its own account; * the list row follows only when the list holds that account. */ - setKeywordForEmails: (emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail) => Promise; + setKeywordForEmails: ( + emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail, options?: KeywordChangeOptions, + ) => Promise; + /** + * A tag sheet closed: its changes' Undo shows for the bar's full time from + * now, as it sat under the sheet so far. + */ + releaseUndoGroup: (undoGroup: string) => void; undoLast: () => Promise; clearUndo: () => void; searchEmails: (query: string) => Promise; @@ -2156,14 +2206,18 @@ export const useEmailStore = create()( if (refusal) throw refusal; }, - setKeywordForEmails: async (emailIds, token, on, viewed) => { + setKeywordForEmails: async (emailIds, token, on, viewed, options) => { const state = get(); const { targets, listed } = actionTargets(state, emailIds, viewed); if (targets.length === 0) return; + const undoGroup = options?.undoGroup; + // The list's changes get an Undo; the viewer's only from its tag sheet. + const offersUndo = (!viewed || !!undoGroup) && keywordUndoLabel(token, on, targets.length) !== null; // While this change is on its way the bar must not still offer the // previous one's Undo: tapping it right after a star undid the tag set // before, and the star stayed. - if (!viewed && keywordUndoLabel(token, on, targets.length) && get().pendingUndo) set({ pendingUndo: null }); + const before = get().pendingUndo; + if (offersUndo && before && !(undoGroup && before.undoGroup === undoGroup)) set({ pendingUndo: null }); // One Email/set per account the selection spans (#1082); the viewer's // message goes to the account it names. const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets); @@ -2214,29 +2268,44 @@ 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). + // Undo for a read, star or tag change from the list, or a tag change in + // the viewer's tag sheet (webmail lib/email-action-toast.ts + // setSelectionTagWithUndo): each message gets back the keyword it had. + // The reader's own star and read 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; + const label = offersUndo && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null; if (label) { + const items: UndoEntry['items'] = changed.map(({ email, accountId }) => ({ + email, + originalMailboxIds: { ...email.mailboxIds }, + originalKeywords: { ...email.keywords }, + accountId, + ...(listed ? {} : { unlisted: true }), + })); + const current = get().pendingUndo; + const joined = undoGroup && current?.kind === 'keywords' && current.undoGroup === undoGroup + ? joinKeywordUndo(current, items, patch) + : { keywordPatch: patch, items }; set({ pendingUndo: { kind: 'keywords', label, createdAt: Date.now(), - keywordPatch: patch, - items: changed.map(({ email, accountId }) => ({ - email, - originalMailboxIds: { ...email.mailboxIds }, - originalKeywords: { ...email.keywords }, - accountId, - })), + ...(undoGroup ? { undoGroup } : {}), + ...joined, }, }); } if (refusal) throw refusal; }, + releaseUndoGroup: (undoGroup) => { + const entry = get().pendingUndo; + if (!entry || entry.undoGroup !== undoGroup) return; + const { undoGroup: _done, ...rest } = entry; + set({ pendingUndo: { ...rest, createdAt: Date.now() } }); + }, + undoLast: async () => { const entry = get().pendingUndo; if (!entry) return; @@ -2250,6 +2319,7 @@ export const useEmailStore = create()( id: it.email.id, key: rowKeyOf(it.email), accountId: it.accountId ?? entry.accountId, + listed: !it.unlisted, patch: revertKeywordPatch(keywordPatch, it.originalKeywords), })); const accounts = new Set(undo.map((u) => u.accountId ?? '')); @@ -2269,14 +2339,19 @@ export const useEmailStore = create()( 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])); + const byKey = new Map(undo.filter((u) => u.listed).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); + for (const u of undo) { + patchCache(u.id, { keywords: u.patch }, u.accountId); + // The open viewer reads the message from the detail cache. + const held = peekDetail(u.id, u.accountId); + if (held) patchDetail(u.id, u.accountId, { keywords: applyKeywordPatch(held.keywords, u.patch) }); + } return; }