mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: ask before deleting or archiving when the confirm settings are on
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user