feat: offer undo after changing tags in the tag sheet and the reader

This commit is contained in:
Linus Rath
2026-10-31 13:52:37 +01:00
parent ecf814b66e
commit 9763036c70
5 changed files with 181 additions and 19 deletions
+22
View File
@@ -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 : '';
}
+4 -1
View File
@@ -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<Set<string>>(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));
};
+6 -3
View File
@@ -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<EmailAddress | null>(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);
});
@@ -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] });
+90 -15
View File
@@ -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<UndoEntry, 'keywordPatch' | 'items'> {
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<void>;
setKeywordForEmails: (
emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail, options?: KeywordChangeOptions,
) => Promise<void>;
/**
* 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<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
@@ -2156,14 +2206,18 @@ export const useEmailStore = create<EmailState>()(
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<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).
// 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<EmailState>()(
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<EmailState>()(
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;
}