diff --git a/src/lib/__tests__/mail-removal-confirm.test.ts b/src/lib/__tests__/mail-removal-confirm.test.ts new file mode 100644 index 00000000..45859c81 --- /dev/null +++ b/src/lib/__tests__/mail-removal-confirm.test.ts @@ -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) => + `${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(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(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(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\)/); + }); +}); diff --git a/src/lib/mail-removal-confirm.ts b/src/lib/mail-removal-confirm.ts new file mode 100644 index 00000000..f1f795f3 --- /dev/null +++ b/src/lib/mail-removal-confirm.ts @@ -0,0 +1,56 @@ +import { Alert } from 'react-native'; +import { useSettingsStore } from '../stores/settings-store'; + +type Translate = (key: string, fallback?: string, params?: Record) => 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; + +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 { + 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, + }); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index b9308448..5bb19fd6 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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(); }; diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index efb7a9ab..80e77f70 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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 diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 94470533..a912c350 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -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 => { + 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 => { 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' && ( { 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) { )} { void remove(selectedEmails).then(clearSelection); }} + onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }} style={styles.headerBtn} hitSlop={6} accessibilityRole="button"