fix: say when a swipe, bulk action or import fails instead of failing silently

This commit is contained in:
Linus Rath
2026-10-15 03:20:48 +02:00
parent e94b782f8e
commit 0db4bf00aa
3 changed files with 71 additions and 19 deletions
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, vi } from 'vitest';
const error = vi.fn();
vi.mock('../../stores/toast-store', () => ({ toast: { error: (...a: unknown[]) => error(...a) } }));
vi.mock('../../stores/locale-store', () => ({
useLocaleStore: { getState: () => ({ t: (_k: string, fallback: string) => fallback }) },
}));
import { reportFailure } from '../report-failure';
describe('reportFailure', () => {
it('shows a toast when the action is refused', async () => {
reportFailure(Promise.reject(new Error('notUpdated: forbidden')));
await new Promise((r) => setTimeout(r, 0));
expect(error).toHaveBeenCalledWith('Failed to update email', 'notUpdated: forbidden');
});
it('stays quiet when the action works', async () => {
error.mockClear();
reportFailure(Promise.resolve());
await new Promise((r) => setTimeout(r, 0));
expect(error).not.toHaveBeenCalled();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { toast } from '../stores/toast-store';
import { useLocaleStore } from '../stores/locale-store';
/**
* Runs a store action the screen doesn't wait for (a swipe, a bulk button)
* and says when it fails. These used to be `void action()`: a change the
* server refused (not a dropped connection, which the outbox queues) went
* unnoticed.
*/
export function reportFailure(action: Promise<unknown>): void {
action.catch((err: unknown) => {
toast.error(
useLocaleStore.getState().t('notifications.error_updating', 'Failed to update email'),
err instanceof Error ? err.message : undefined,
);
});
}
+30 -19
View File
@@ -33,6 +33,7 @@ import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/se
import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back'; import { listBackStep } from '../lib/list-back';
import { reportFailure } from '../lib/report-failure';
import { useLayout } from '../lib/use-layout'; import { useLayout } from '../lib/use-layout';
import { useSendUndoStore } from '../stores/send-undo-store'; import { useSendUndoStore } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
@@ -535,6 +536,12 @@ export default function EmailListScreen({
? t('email_list.import.none', 'No messages were imported.') ? t('email_list.import.none', 'No messages were imported.')
: t('email_list.import.success', '{count, plural, one {# message imported.} other {# messages imported.}}', { count: imported }), : t('email_list.import.success', '{count, plural, one {# message imported.} other {# messages imported.}}', { count: imported }),
); );
} catch (err) {
// A failed import used to end without a word.
Alert.alert(
t('email_list.import.title', 'Import'),
err instanceof Error ? err.message : t('email_list.import.none', 'No messages were imported.'),
);
} finally { } finally {
setImporting(false); setImporting(false);
} }
@@ -644,8 +651,8 @@ export default function EmailListScreen({
case 'archive': case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
const ids = idsForRow(id); const ids = idsForRow(id);
if (ids.length > 1) void archiveEmailsBatch(ids); if (ids.length > 1) reportFailure(archiveEmailsBatch(ids));
else void archiveEmailAction(id); else reportFailure(archiveEmailAction(id));
} else if (!archiveMailboxId) { } else if (!archiveMailboxId) {
Alert.alert( Alert.alert(
t('email_list.error', 'Error'), t('email_list.error', 'Error'),
@@ -654,16 +661,16 @@ export default function EmailListScreen({
} }
break; break;
case 'delete': case 'delete':
void deleteIds(idsForRow(id)); reportFailure(deleteIds(idsForRow(id)));
break; break;
case 'spam': case 'spam':
// Your own outgoing mail is never spam (webmail hides the action in // Your own outgoing mail is never spam (webmail hides the action in
// Sent/Drafts); inside Junk the same swipe means "not spam". // Sent/Drafts); inside Junk the same swipe means "not spam".
if (currentRole === 'sent' || currentRole === 'drafts') break; if (currentRole === 'sent' || currentRole === 'drafts') break;
if (inJunk) { if (inJunk) {
void unmarkSpam(idsForRow(id)); reportFailure(unmarkSpam(idsForRow(id)));
} else if (junkMailboxId) { } else if (junkMailboxId) {
void markSpam(idsForRow(id)); reportFailure(markSpam(idsForRow(id)));
} else { } else {
Alert.alert( Alert.alert(
t('email_list.error', 'Error'), t('email_list.error', 'Error'),
@@ -672,14 +679,14 @@ export default function EmailListScreen({
} }
break; break;
case 'read': case 'read':
if (isUnread(email)) void markRead(id); if (isUnread(email)) reportFailure(markRead(id));
else void markUnread(id); else reportFailure(markUnread(id));
break; break;
case 'star': case 'star':
void toggleStar(id, !isStarred(email)); reportFailure(toggleStar(id, !isStarred(email)));
break; break;
case 'pin': case 'pin':
void togglePin(id, !isPinned(email)); reportFailure(togglePin(id, !isPinned(email)));
break; break;
case 'move': case 'move':
setPendingMoveId(id); setPendingMoveId(id);
@@ -788,7 +795,11 @@ export default function EmailListScreen({
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
const trash = findTrashMailbox(scopedMailboxes); const trash = findTrashMailbox(scopedMailboxes);
if (!trash || !currentMailboxId) { if (!trash || !currentMailboxId) {
clearSelection(); // Said, as the swipe says it, instead of dropping the selection.
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
);
return; return;
} }
const ids = selectedMessageIds; const ids = selectedMessageIds;
@@ -824,11 +835,11 @@ export default function EmailListScreen({
const ids = selectedMessageIds; const ids = selectedMessageIds;
setBatchMoveOpen(false); setBatchMoveOpen(false);
clearSelection(); clearSelection();
void moveEmailsToMailbox(ids, toId); reportFailure(moveEmailsToMailbox(ids, toId));
}; };
const handleBatchTagToggle = (token: string, on: boolean) => { const handleBatchTagToggle = (token: string, on: boolean) => {
void setKeywordForEmails(selectedMessageIds, token, on); reportFailure(setKeywordForEmails(selectedMessageIds, token, on));
}; };
// Local input state for uninterrupted typing. The search runs on submit, // Local input state for uninterrupted typing. The search runs on submit,
@@ -1040,7 +1051,7 @@ export default function EmailListScreen({
{t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size })} {t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size })}
</Text> </Text>
<Pressable <Pressable
onPress={() => { void handleBulkStar(); }} onPress={() => reportFailure(handleBulkStar())}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -1053,7 +1064,7 @@ export default function EmailListScreen({
/> />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => { void handleBulkMarkReadToggle(); }} onPress={() => reportFailure(handleBulkMarkReadToggle())}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -1087,7 +1098,7 @@ export default function EmailListScreen({
</Pressable> </Pressable>
{canSpamSelection && ( {canSpamSelection && (
<Pressable <Pressable
onPress={() => { void handleBulkSpam(); }} onPress={() => reportFailure(handleBulkSpam())}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -1102,7 +1113,7 @@ export default function EmailListScreen({
)} )}
{canArchiveSelection && ( {canArchiveSelection && (
<Pressable <Pressable
onPress={() => { void handleBulkArchive(); }} onPress={() => reportFailure(handleBulkArchive())}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -1112,7 +1123,7 @@ export default function EmailListScreen({
</Pressable> </Pressable>
)} )}
<Pressable <Pressable
onPress={() => { void handleBulkDelete(); }} onPress={() => reportFailure(handleBulkDelete())}
style={styles.headerButton} style={styles.headerButton}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -1772,8 +1783,8 @@ export default function EmailListScreen({
setPendingMoveId(null); setPendingMoveId(null);
if (id && currentMailboxId && toId !== currentMailboxId) { if (id && currentMailboxId && toId !== currentMailboxId) {
const ids = idsForRow(id); const ids = idsForRow(id);
if (ids.length > 1) void moveEmailsToMailbox(ids, toId); if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, toId));
else void moveToMailboxAction(id, currentMailboxId, toId); else reportFailure(moveToMailboxAction(id, currentMailboxId, toId));
} }
}} }}
/> />