mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
feat: offer undo after changing tags in the tag sheet and the reader
This commit is contained in:
@@ -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 : '';
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import SenderAvatar from '../components/SenderAvatar';
|
|||||||
import { SwipeableRow } from '../components/SwipeableRow';
|
import { SwipeableRow } from '../components/SwipeableRow';
|
||||||
import { MoveSheet } from '../components/MoveSheet';
|
import { MoveSheet } from '../components/MoveSheet';
|
||||||
import { TagSheet } from '../components/TagSheet';
|
import { TagSheet } from '../components/TagSheet';
|
||||||
|
import { useTagUndoGroup } from '../lib/tag-undo-group';
|
||||||
import { OfflineBanner } from '../components/OfflineBanner';
|
import { OfflineBanner } from '../components/OfflineBanner';
|
||||||
import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink';
|
import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink';
|
||||||
import {
|
import {
|
||||||
@@ -711,6 +712,8 @@ export default function EmailListScreen({
|
|||||||
// Batch (multi-select) sheets.
|
// Batch (multi-select) sheets.
|
||||||
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
||||||
const [tagSheetOpen, setTagSheetOpen] = 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
|
// Selection state
|
||||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||||
@@ -1030,7 +1033,7 @@ export default function EmailListScreen({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchTagToggle = (token: string, on: boolean) => {
|
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));
|
reportKeywordFailure(t('notifications.tag_failed', 'Tagging failed'), err));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { pickEmailBody } from '../lib/email-body';
|
import { pickEmailBody } from '../lib/email-body';
|
||||||
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||||
|
import { useTagUndoGroup } from '../lib/tag-undo-group';
|
||||||
import { NewTagRow } from '../components/NewTagRow';
|
import { NewTagRow } from '../components/NewTagRow';
|
||||||
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
||||||
import { isSieveSupported } from '../api/sieve';
|
import { isSieveSupported } from '../api/sieve';
|
||||||
@@ -177,6 +178,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||||
const [tagMenuOpen, setTagMenuOpen] = 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 [rulesOpen, setRulesOpen] = React.useState(false);
|
||||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
// 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
|
// list row follows when the list holds that account (never a same-id row
|
||||||
// of another one).
|
// of another one).
|
||||||
const setKeyword = React.useCallback(
|
const setKeyword = React.useCallback(
|
||||||
(target: Email, token: string, on: boolean) =>
|
(target: Email, token: string, on: boolean, undoGroup?: string) =>
|
||||||
setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }),
|
setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }, { undoGroup }),
|
||||||
[setKeywordForEmails, ownerAccountId],
|
[setKeywordForEmails, ownerAccountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -473,7 +476,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
if (next[token]) delete next[token];
|
if (next[token]) delete next[token];
|
||||||
else next[token] = true;
|
else next[token] = true;
|
||||||
updateLocalKeywords(email.id, next);
|
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);
|
updateLocalKeywords(email.id, email.keywords);
|
||||||
toastFailure(t('notifications.tag_failed', 'Tagging failed'), err);
|
toastFailure(t('notifications.tag_failed', 'Tagging failed'), err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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
|
// Actions the viewer runs on the message it shows, which may live in another
|
||||||
// account than the open folder (unified inbox, notification, deep link).
|
// account than the open folder (unified inbox, notification, deep link).
|
||||||
@@ -129,6 +131,63 @@ beforeEach(() => {
|
|||||||
useEmailStore.getState().reset();
|
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', () => {
|
describe('keyword undo', () => {
|
||||||
it('undoes a star made after a tag', async () => {
|
it('undoes a star made after a tag', async () => {
|
||||||
useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] });
|
useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] });
|
||||||
|
|||||||
+90
-15
@@ -55,6 +55,7 @@ import { useSettingsStore } from './settings-store';
|
|||||||
import { useOfflineCacheStore } from './offline-cache-store';
|
import { useOfflineCacheStore } from './offline-cache-store';
|
||||||
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
|
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
|
||||||
import { useTagCountsStore } from './tag-counts-store';
|
import { useTagCountsStore } from './tag-counts-store';
|
||||||
|
import { patchDetail, peekDetail } from '../lib/email-detail-cache';
|
||||||
|
|
||||||
// ── Refresh coalescing ─────────────────────────────────────────────────
|
// ── Refresh coalescing ─────────────────────────────────────────────────
|
||||||
// Push events, mount effects and post-action follow-ups all call
|
// Push events, mount effects and post-action follow-ups all call
|
||||||
@@ -358,6 +359,11 @@ export interface UndoEntry {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
||||||
createdAt: number;
|
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. */
|
/** JMAP account the messages live under; unset for the user's own mail. */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
/**
|
/**
|
||||||
@@ -380,9 +386,46 @@ export interface UndoEntry {
|
|||||||
* (a list that spans accounts); `accountId` above applies otherwise.
|
* (a list that spans accounts); `accountId` above applies otherwise.
|
||||||
*/
|
*/
|
||||||
accountId?: string;
|
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).
|
// 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
|
// `queryState` is the JMAP queryState for the matching Email/query, used to
|
||||||
// drive Email/queryChanges on the next refresh.
|
// 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;
|
* `Email/set`. With `viewed`, on the viewer's message in its own account;
|
||||||
* the list row follows only when the list holds that 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>;
|
undoLast: () => Promise<void>;
|
||||||
clearUndo: () => void;
|
clearUndo: () => void;
|
||||||
searchEmails: (query: string) => Promise<Email[]>;
|
searchEmails: (query: string) => Promise<Email[]>;
|
||||||
@@ -2156,14 +2206,18 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
if (refusal) throw refusal;
|
if (refusal) throw refusal;
|
||||||
},
|
},
|
||||||
|
|
||||||
setKeywordForEmails: async (emailIds, token, on, viewed) => {
|
setKeywordForEmails: async (emailIds, token, on, viewed, options) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const { targets, listed } = actionTargets(state, emailIds, viewed);
|
const { targets, listed } = actionTargets(state, emailIds, viewed);
|
||||||
if (targets.length === 0) return;
|
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
|
// 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
|
// previous one's Undo: tapping it right after a star undid the tag set
|
||||||
// before, and the star stayed.
|
// 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
|
// One Email/set per account the selection spans (#1082); the viewer's
|
||||||
// message goes to the account it names.
|
// message goes to the account it names.
|
||||||
const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets);
|
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 { accountId, emails } of written) {
|
||||||
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
|
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
|
// Undo for a read, star or tag change from the list, or a tag change in
|
||||||
// back the keyword it had (the reader's own toggles get no bar).
|
// 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 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) {
|
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({
|
set({
|
||||||
pendingUndo: {
|
pendingUndo: {
|
||||||
kind: 'keywords',
|
kind: 'keywords',
|
||||||
label,
|
label,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
keywordPatch: patch,
|
...(undoGroup ? { undoGroup } : {}),
|
||||||
items: changed.map(({ email, accountId }) => ({
|
...joined,
|
||||||
email,
|
|
||||||
originalMailboxIds: { ...email.mailboxIds },
|
|
||||||
originalKeywords: { ...email.keywords },
|
|
||||||
accountId,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (refusal) throw refusal;
|
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 () => {
|
undoLast: async () => {
|
||||||
const entry = get().pendingUndo;
|
const entry = get().pendingUndo;
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -2250,6 +2319,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
id: it.email.id,
|
id: it.email.id,
|
||||||
key: rowKeyOf(it.email),
|
key: rowKeyOf(it.email),
|
||||||
accountId: it.accountId ?? entry.accountId,
|
accountId: it.accountId ?? entry.accountId,
|
||||||
|
listed: !it.unlisted,
|
||||||
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
|
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
|
||||||
}));
|
}));
|
||||||
const accounts = new Set(undo.map((u) => u.accountId ?? ''));
|
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') });
|
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
|
||||||
return;
|
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({
|
set({
|
||||||
emails: get().emails.map((e) => {
|
emails: get().emails.map((e) => {
|
||||||
const patch = byKey.get(rowKeyOf(e));
|
const patch = byKey.get(rowKeyOf(e));
|
||||||
return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user