mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
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`)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<RootStackParamList, 'EmailThread'>;
|
||||
|
||||
// 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) {
|
||||
/>
|
||||
|
||||
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
|
||||
|
||||
{/* The list's host sits under this screen; failures in here need their own. */}
|
||||
<ToastHost />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user