From 0db4bf00aa6a5e2db3ed14aaaef44677445ed983 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:20:48 +0200 Subject: [PATCH] fix: say when a swipe, bulk action or import fails instead of failing silently --- src/lib/__tests__/report-failure.test.ts | 24 ++++++++++++ src/lib/report-failure.ts | 17 ++++++++ src/screens/EmailListScreen.tsx | 49 +++++++++++++++--------- 3 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 src/lib/__tests__/report-failure.test.ts create mode 100644 src/lib/report-failure.ts diff --git a/src/lib/__tests__/report-failure.test.ts b/src/lib/__tests__/report-failure.test.ts new file mode 100644 index 00000000..00ac2bd4 --- /dev/null +++ b/src/lib/__tests__/report-failure.test.ts @@ -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(); + }); +}); diff --git a/src/lib/report-failure.ts b/src/lib/report-failure.ts new file mode 100644 index 00000000..6deeffeb --- /dev/null +++ b/src/lib/report-failure.ts @@ -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): void { + action.catch((err: unknown) => { + toast.error( + useLocaleStore.getState().t('notifications.error_updating', 'Failed to update email'), + err instanceof Error ? err.message : undefined, + ); + }); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index e9509004..a69215f1 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -33,6 +33,7 @@ import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/se import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { listBackStep } from '../lib/list-back'; +import { reportFailure } from '../lib/report-failure'; import { useLayout } from '../lib/use-layout'; import { useSendUndoStore } from '../stores/send-undo-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.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 { setImporting(false); } @@ -644,8 +651,8 @@ export default function EmailListScreen({ case 'archive': if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { const ids = idsForRow(id); - if (ids.length > 1) void archiveEmailsBatch(ids); - else void archiveEmailAction(id); + if (ids.length > 1) reportFailure(archiveEmailsBatch(ids)); + else reportFailure(archiveEmailAction(id)); } else if (!archiveMailboxId) { Alert.alert( t('email_list.error', 'Error'), @@ -654,16 +661,16 @@ export default function EmailListScreen({ } break; case 'delete': - void deleteIds(idsForRow(id)); + reportFailure(deleteIds(idsForRow(id))); break; case 'spam': // Your own outgoing mail is never spam (webmail hides the action in // Sent/Drafts); inside Junk the same swipe means "not spam". if (currentRole === 'sent' || currentRole === 'drafts') break; if (inJunk) { - void unmarkSpam(idsForRow(id)); + reportFailure(unmarkSpam(idsForRow(id))); } else if (junkMailboxId) { - void markSpam(idsForRow(id)); + reportFailure(markSpam(idsForRow(id))); } else { Alert.alert( t('email_list.error', 'Error'), @@ -672,14 +679,14 @@ export default function EmailListScreen({ } break; case 'read': - if (isUnread(email)) void markRead(id); - else void markUnread(id); + if (isUnread(email)) reportFailure(markRead(id)); + else reportFailure(markUnread(id)); break; case 'star': - void toggleStar(id, !isStarred(email)); + reportFailure(toggleStar(id, !isStarred(email))); break; case 'pin': - void togglePin(id, !isPinned(email)); + reportFailure(togglePin(id, !isPinned(email))); break; case 'move': setPendingMoveId(id); @@ -788,7 +795,11 @@ export default function EmailListScreen({ const handleBulkDelete = async () => { const trash = findTrashMailbox(scopedMailboxes); 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; } const ids = selectedMessageIds; @@ -824,11 +835,11 @@ export default function EmailListScreen({ const ids = selectedMessageIds; setBatchMoveOpen(false); clearSelection(); - void moveEmailsToMailbox(ids, toId); + reportFailure(moveEmailsToMailbox(ids, toId)); }; 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, @@ -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 })} { void handleBulkStar(); }} + onPress={() => reportFailure(handleBulkStar())} style={styles.headerButton} hitSlop={6} accessibilityRole="button" @@ -1053,7 +1064,7 @@ export default function EmailListScreen({ /> { void handleBulkMarkReadToggle(); }} + onPress={() => reportFailure(handleBulkMarkReadToggle())} style={styles.headerButton} hitSlop={6} accessibilityRole="button" @@ -1087,7 +1098,7 @@ export default function EmailListScreen({ {canSpamSelection && ( { void handleBulkSpam(); }} + onPress={() => reportFailure(handleBulkSpam())} style={styles.headerButton} hitSlop={6} accessibilityRole="button" @@ -1102,7 +1113,7 @@ export default function EmailListScreen({ )} {canArchiveSelection && ( { void handleBulkArchive(); }} + onPress={() => reportFailure(handleBulkArchive())} style={styles.headerButton} hitSlop={6} accessibilityRole="button" @@ -1112,7 +1123,7 @@ export default function EmailListScreen({ )} { void handleBulkDelete(); }} + onPress={() => reportFailure(handleBulkDelete())} style={styles.headerButton} hitSlop={6} accessibilityRole="button" @@ -1772,8 +1783,8 @@ export default function EmailListScreen({ setPendingMoveId(null); if (id && currentMailboxId && toId !== currentMailboxId) { const ids = idsForRow(id); - if (ids.length > 1) void moveEmailsToMailbox(ids, toId); - else void moveToMailboxAction(id, currentMailboxId, toId); + if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, toId)); + else reportFailure(moveToMailboxAction(id, currentMailboxId, toId)); } }} />