mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +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 { 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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user