feat: ask before deleting or archiving when the confirm settings are on

This commit is contained in:
Linus Rath
2026-10-31 03:54:33 +01:00
parent a3650ebf34
commit 9794dc03f7
5 changed files with 160 additions and 14 deletions
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSettingsStore } from '../../stores/settings-store';
import { confirmMailRemoval, type AskFn } from '../mail-removal-confirm';
// "Confirm before deleting" and "Confirm before archiving" existed as
// settings but nothing asked: delete and archive went ahead from the open
// message, the selection bar and swipes (webmail lib/mail-removal-confirm.ts).
const t = (key: string, fallback?: string, params?: Record<string, string | number>) =>
`${key}${params?.count !== undefined ? `:${params.count}` : ''}${fallback ? '' : ''}`;
describe('confirmMailRemoval', () => {
beforeEach(async () => {
await AsyncStorage.clear();
useSettingsStore.getState().resetToDefaults();
});
it('goes ahead without asking while the settings are off', async () => {
const ask = vi.fn<AskFn>(async () => false);
expect(await confirmMailRemoval('delete', 2, t, ask)).toBe(true);
expect(await confirmMailRemoval('archive', 2, t, ask)).toBe(true);
expect(ask).not.toHaveBeenCalled();
});
it('asks before a delete when "Confirm before deleting" is on, and stops on no', async () => {
useSettingsStore.setState({ confirmDelete: true });
const no = vi.fn<AskFn>(async () => false);
expect(await confirmMailRemoval('delete', 3, t, no)).toBe(false);
expect(no).toHaveBeenCalledWith(expect.objectContaining({
title: 'email_list.confirm_remove.delete_title:3', destructive: true,
}));
expect(await confirmMailRemoval('delete', 3, t, async () => true)).toBe(true);
// Archiving has its own switch.
expect(await confirmMailRemoval('archive', 3, t, no)).toBe(true);
expect(no).toHaveBeenCalledTimes(1);
});
it('asks before an archive when "Confirm before archiving" is on', async () => {
useSettingsStore.setState({ confirmArchive: true });
const ask = vi.fn<AskFn>(async () => false);
expect(await confirmMailRemoval('archive', 1, t, ask)).toBe(false);
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: 'email_list.confirm_remove.archive_title:1', destructive: false }));
expect(await confirmMailRemoval('archive', 0, t, ask)).toBe(true);
});
it('is asked from the open message, the selection bar and swipes, but not on top of the permanent delete question', () => {
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
const thread = src('screens/EmailThreadScreen.tsx');
expect(thread).toMatch(/confirmMailRemoval\('delete', 1, t\)/);
expect(thread).toMatch(/confirmMailRemoval\('archive', 1, t\)/);
const list = src('screens/EmailListScreen.tsx');
// deleteIds (swipe) and the selection bar's delete, each in the
// non-permanent branch only.
expect(list.match(/\} else if \(!\(await confirmMailRemoval\('delete', ids\.length, t\)\)\) \{/g)).toHaveLength(2);
expect(list).toMatch(/confirmMailRemoval\('archive', ids\.length, t\)/);
expect(list).toMatch(/confirmMailRemoval\('archive', selectedMessageIds\.length, t\)/);
const unified = src('screens/UnifiedInboxScreen.tsx');
expect(unified).toMatch(/confirmMailRemoval\('archive', targets\.length, t\)/);
expect(unified).toMatch(/confirmMailRemoval\('delete', targets\.length, t\)/);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { Alert } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/** Shows the question; resolves true when the user went ahead. */
export type AskFn = (question: { title: string; message: string; confirmText: string; destructive: boolean; cancelText: string }) => Promise<boolean>;
const alertAsk: AskFn = ({ title, message, confirmText, destructive, cancelText }) => new Promise((resolve) => {
Alert.alert(
title,
message,
[
{ text: cancelText, style: 'cancel', onPress: () => resolve(false) },
{ text: confirmText, style: destructive ? 'destructive' : 'default', onPress: () => resolve(true) },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
/**
* Asks before messages are deleted or archived when the user turned on
* "Confirm before deleting" or "Confirm before archiving" (webmail
* lib/mail-removal-confirm.ts). Applies to the open message, the selection
* bar and swipes. Resolves true when the action may go ahead: the setting is
* off, or the user confirmed.
*
* A permanent delete has its own, stronger question; ask this one only when
* no other confirmation is shown, so nobody is asked twice.
*/
export async function confirmMailRemoval(
kind: 'delete' | 'archive',
count: number,
t: Translate,
ask: AskFn = alertAsk,
): Promise<boolean> {
const settings = useSettingsStore.getState();
if (!(kind === 'delete' ? settings.confirmDelete : settings.confirmArchive)) return true;
if (count <= 0) return true;
const params = { count };
return kind === 'delete'
? ask({
title: t('email_list.confirm_remove.delete_title', '{count, plural, one {Delete this email?} other {Delete # emails?}}', params),
message: t('email_list.confirm_remove.delete_message', '{count, plural, one {It moves to the trash.} other {They move to the trash.}}', params),
confirmText: t('email_list.confirm_remove.delete', 'Delete', params),
cancelText: t('common.cancel', 'Cancel'),
destructive: true,
})
: ask({
title: t('email_list.confirm_remove.archive_title', '{count, plural, one {Archive this email?} other {Archive # emails?}}', params),
message: t('email_list.confirm_remove.archive_message', '{count, plural, one {It moves to your archive.} other {They move to your archive.}}', params),
confirmText: t('email_list.confirm_remove.archive', 'Archive', params),
cancelText: t('common.cancel', 'Cancel'),
destructive: false,
});
}
+16 -4
View File
@@ -56,6 +56,7 @@ import {
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
} from '../lib/thread-utils';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
@@ -725,7 +726,11 @@ export default function EmailListScreen({
deleteAction,
permanentlyDeleteJunk,
});
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(ids.length, t))) return;
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
return;
}
if (ids.length === 1) await deleteEmailAction(ids[0], trashMailboxId, currentMailboxId);
else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId);
}, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]);
@@ -738,8 +743,10 @@ export default function EmailListScreen({
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
const ids = idsForRow(id);
if (ids.length > 1) reportFailure(archiveEmailsBatch(ids));
else reportFailure(archiveEmailAction(id));
reportFailure(confirmMailRemoval('archive', ids.length, t).then((ok) => {
if (!ok) return undefined;
return ids.length > 1 ? archiveEmailsBatch(ids) : archiveEmailAction(id);
}));
} else if (!archiveMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
@@ -904,12 +911,17 @@ export default function EmailListScreen({
deleteAction,
permanentlyDeleteJunk,
});
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(ids.length, t))) return;
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
return;
}
await deleteEmailsBatch(ids, trash.id, currentMailboxId);
clearSelection();
};
const handleBulkArchive = async () => {
if (!(await confirmMailRemoval('archive', selectedMessageIds.length, t))) return;
await archiveEmailsBatch(selectedMessageIds);
clearSelection();
};
+9 -4
View File
@@ -14,6 +14,7 @@ import {
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type,
} from '../components/icons';
import { pickEmailBody } from '../lib/email-body';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
import { isSieveSupported } from '../api/sieve';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -545,14 +546,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
);
return;
}
performDelete();
// "Confirm before deleting" (webmail lib/mail-removal-confirm.ts).
void confirmMailRemoval('delete', 1, t).then((ok) => { if (ok) performDelete(); });
};
const onArchive = () => {
if (!email || !canArchive) return;
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
navigation.goBack();
void confirmMailRemoval('archive', 1, t).then((ok) => {
if (!ok) return;
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
navigation.goBack();
});
};
// The same store path as the list and swipe actions: files into the
+15 -6
View File
@@ -25,6 +25,7 @@ import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { onStateChangeType } from '../lib/state-change-bus';
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
import { createUnifiedReloadTracker } from '../lib/unified-reload';
@@ -244,9 +245,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
patchRows(targets, { $flagged: starred ? true : null });
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
};
const archive = (targets: UnifiedEmail[]) => {
// Resolve whether the messages went: a confirm can say no.
const archive = async (targets: UnifiedEmail[]): Promise<boolean> => {
if (!(await confirmMailRemoval('archive', targets.length, t))) return false;
removeRows(targets);
moveUnifiedEmails(targets, 'archive').catch(fail);
return true;
};
const spam = (targets: UnifiedEmail[]) => {
removeRows(targets);
@@ -254,24 +258,29 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
}).catch(fail);
};
const remove = async (targets: UnifiedEmail[]) => {
const remove = async (targets: UnifiedEmail[]): Promise<boolean> => {
const permanent = targets.some((e) => isPermanentDelete({
inTrash: role === 'trash' || isInUnifiedTrash(e),
inJunk: role === 'junk',
deleteAction,
permanentlyDeleteJunk,
}));
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(targets.length, t))) return false;
} else if (!(await confirmMailRemoval('delete', targets.length, t))) {
return false;
}
removeRows(targets);
deleteUnifiedEmails(targets, {
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
markRead: deleteAction === 'trash-and-read',
}).catch(fail);
return true;
};
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
switch (action) {
case 'archive': if (role !== 'archive') archive([email]); break;
case 'archive': if (role !== 'archive') void archive([email]); break;
case 'delete': void remove([email]); break;
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
case 'read': setRead([email], !email.keywords?.$seen); break;
@@ -444,7 +453,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
)}
{role !== 'archive' && (
<Pressable
onPress={() => { archive(selectedEmails); clearSelection(); }}
onPress={() => { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
@@ -454,7 +463,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
</Pressable>
)}
<Pressable
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"