mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: offer undo after marking read or unread, starring or tagging from the list
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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'] }>();
|
||||
|
||||
Reference in New Issue
Block a user