From 9e95273c143bfa532db77658a14f45e4d3165543 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:58:54 +0200 Subject: [PATCH] feat: tell the user when a viewer action fails and when mail is sent The viewer fired its store actions with `void` and went back to the list, so a refused delete, archive, move, spam report, star, tag or read change failed silently, and nothing confirmed a sent message. - each viewer action now reports a failure as an error toast, and a refused star, tag or read change is taken back on screen - the viewer mounts its own toast host, since the list's sits under it - an immediate send from the composer or the quick reply box shows "Email sent"; a send held for the undo delay keeps its undo bar (webmail `ec67ab99`, `b03a0c1d`) --- src/components/email/QuickReplyBox.tsx | 5 ++- src/screens/ComposeScreen.tsx | 4 ++ src/screens/EmailThreadScreen.tsx | 51 +++++++++++++++++++++----- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 1a39f9b8..e857060c 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -7,6 +7,7 @@ import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { useEmailStore } from '../../stores/email-store'; +import { toast } from '../../stores/toast-store'; import { sendEmail, patchKeywordsForEmails } from '../../api/email'; import { jmapClient } from '../../api/jmap-client'; import { buildReplyRecipients } from '../../lib/reply-recipients'; @@ -78,7 +79,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P const quoted = original.split('\n').map((l) => `> ${l}`).join('\n'); const header = `${formatFullDateTime(emailDisplayDate(email), timeFormat, locale)}, ${from.name ? `${from.name} <${from.email}>` : from.email}:`; const threading = computeReplyThreadingHeaders(email); - await sendEmail( + const result = await sendEmail( { from: [{ name: identity.name, email: identity.email }], to: recipients.to.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })), @@ -98,6 +99,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P } catch { /* the reply is out; the flag is cosmetic */ } onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } }); setText(''); + // A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d). + if (!result.scheduled) toast.success(t('notifications.email_sent', 'Email sent successfully')); } catch (err) { Alert.alert(t('email_composer.send_failed', 'Failed to send'), err instanceof Error ? err.message : String(err)); } finally { diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 4e203975..6682176c 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -33,6 +33,7 @@ import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; import { useAccountStore } from '../stores/account-store'; import { useSendUndoStore } from '../stores/send-undo-store'; +import { toast } from '../stores/toast-store'; import { type EmailTemplate } from '../stores/templates-store'; import { getIdentities } from '../api/identity'; import { @@ -2032,6 +2033,9 @@ export default function ComposeScreen({ route, navigation }: Props) { delaySeconds: holdForSeconds, createdAt: Date.now(), }); + } else if (!result.scheduled) { + // Held sends get the undo bar instead (webmail b03a0c1d). + toast.success(t('notifications.email_sent', 'Email sent successfully')); } allowLeaveRef.current = true; navigation.goBack(); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 9cce36f8..fcaffb22 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -19,7 +19,9 @@ import { MessageContent } from '../components/email/MessageContent'; import { ThreadMessageCard } from '../components/email/ThreadMessageCard'; import { QuickReplyBox } from '../components/email/QuickReplyBox'; import { AddressActionSheet } from '../components/email/AddressActionSheet'; +import { ToastHost } from '../components/ToastHost'; import { useEmailStore } from '../stores/email-store'; +import { toast } from '../stores/toast-store'; import { useSettingsStore, normalizeBottomQuickActions, @@ -41,6 +43,14 @@ import type { RootStackParamList } from '../navigation/types'; type Props = NativeStackScreenProps; +// Actions finish after the viewer may already have gone back, so a failure is +// reported as a toast instead of vanishing with the promise (webmail +// `lib/email-action-toast.ts`). +function toastFailure(title: string, err: unknown): void { + console.warn('[viewer]', title, err); + toast.error(title, err instanceof Error ? err.message : undefined); +} + export default function EmailThreadScreen({ route, navigation }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -326,7 +336,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (next[token]) delete next[token]; else next[token] = true; updateLocalKeywords(email.id, next); - void patchKeywordsForEmails([email.id], { [token]: next[token] ?? null }, ownerAccountId); + patchKeywordsForEmails([email.id], { [token]: next[token] ?? null }, ownerAccountId).catch((err) => { + updateLocalKeywords(email.id, email.keywords); + toastFailure(t('notifications.tag_failed', 'Tagging failed'), err); + }); }; // Toggle the star on a specific message — used both by the toolbar (current @@ -336,21 +349,28 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (next.$flagged) delete next.$flagged; else next.$flagged = true; updateLocalKeywords(target.id, next); - void patchKeywordsForEmails([target.id], { $flagged: next.$flagged ?? null }, ownerAccountId); - }, [updateLocalKeywords, ownerAccountId]); + patchKeywordsForEmails([target.id], { $flagged: next.$flagged ?? null }, ownerAccountId).catch((err) => { + updateLocalKeywords(target.id, target.keywords); + toastFailure(t('notifications.error_updating', 'Failed to update email'), err); + }); + }, [updateLocalKeywords, ownerAccountId, t]); const onToggleStar = () => { if (email) toggleStarFor(email); }; const onToggleUnread = () => { if (!email) return; + const failed = (err: unknown) => { + updateLocalKeywords(email.id, email.keywords); + toastFailure(t('notifications.error_updating', 'Failed to update email'), err); + }; if (unread) { - void markSeen(email.id); + markSeen(email.id).catch(failed); updateLocalKeywords(email.id, { ...email.keywords, $seen: true }); } else { const next = { ...email.keywords }; delete next.$seen; updateLocalKeywords(email.id, next); - void patchKeywordsForEmails([email.id], { $seen: null }, ownerAccountId); + patchKeywordsForEmails([email.id], { $seen: null }, ownerAccountId).catch(failed); } }; @@ -361,7 +381,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const performDelete = () => { if (!email || !sourceMailbox || !trashMailbox) return; - void deleteEmail(email.id, trashMailbox.id, sourceMailbox.id, { email, accountId: ownerAccountId }); + deleteEmail(email.id, trashMailbox.id, sourceMailbox.id, { email, accountId: ownerAccountId }) + .catch((err) => toastFailure(t('notifications.error_deleting', 'Failed to delete email'), err)); navigation.goBack(); }; @@ -394,7 +415,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const onArchive = () => { if (!email || !canArchive) return; - void archiveEmailAction(email.id, { email, accountId: ownerAccountId }); + archiveEmailAction(email.id, { email, accountId: ownerAccountId }) + .catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err)); navigation.goBack(); }; @@ -405,8 +427,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!email || !canToggleSpam) return; setMoreMenuOpen(false); const viewed = { email, accountId: ownerAccountId }; - if (isInJunk) void unmarkSpam([email.id], viewed); - else void markSpam([email.id], viewed); + if (isInJunk) { + unmarkSpam([email.id], viewed) + .catch((err) => toastFailure(t('email_viewer.spam.error_not_spam', 'Failed to restore email'), err)); + } else { + markSpam([email.id], viewed) + .catch((err) => toastFailure(t('email_viewer.spam.error', 'Failed to report spam'), err)); + } navigation.goBack(); }; @@ -414,7 +441,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!email || !sourceMailbox || toId === sourceMailbox.id) return; setMoveMenuOpen(false); setMoreMenuOpen(false); - void moveToMailbox(email.id, sourceMailbox.id, toId, { email, accountId: ownerAccountId }); + moveToMailbox(email.id, sourceMailbox.id, toId, { email, accountId: ownerAccountId }) + .catch((err) => toastFailure(t('notifications.move_failed', 'Move failed'), err)); navigation.goBack(); }; @@ -786,6 +814,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) { /> setAddressSheet(null)} /> + + {/* The list's host sits under this screen; failures in here need their own. */} + ); }