fix: offer undo for replies sent from the viewer

A quick reply held by the undo-send delay could not be undone: the
undo bar lived only on the mail list, and the quick reply never
recorded the held send for it.

- The undo bar is now hosted on every stack screen through the
  navigator's `screenLayout`, next to the toasts, and only the focused
  screen's renders, so it shows wherever the send started and never
  twice. The list's own instance is gone; archive, delete, move and
  spam undo use the same bar as before. The composer gets none, since
  its Undo reopens the composer.
- The quick reply records a held send through the same store action as
  the composer, including the account it went out of, so Undo and
  Send now reach a shared account's submission and Undo restores the
  reply into that account's Drafts.
- The quick reply dismisses the keyboard after sending, which would
  otherwise cover the bar.
This commit is contained in:
Linus Rath
2026-09-24 21:16:59 +02:00
parent 5e3cbf2419
commit e3501f939d
7 changed files with 273 additions and 41 deletions
+20 -10
View File
@@ -67,6 +67,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { UndoSnackbar } from './src/components/UndoSnackbar';
import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
@@ -150,20 +151,29 @@ async function openDeepLink(link: DeepLink): Promise<void> {
});
}
// Toasts show on whichever stack screen is up, so a failure that lands after
// leaving the screen it came from (a viewer action finishing on the Unified
// Inbox or a contact) is still seen. Every screen carries a host, and only
// the focused one renders, so a toast never shows twice (the screen below
// the top one is kept live).
function FocusedToastHost() {
return useIsFocused() ? <ToastHost /> : null;
// Toasts and the undo bar show on whichever stack screen is up, so a failure
// that lands after leaving the screen it came from (a viewer action finishing
// on the Unified Inbox or a contact) is still seen, and a reply held by the
// undo-send delay can be undone from the viewer it was sent from. Every
// screen carries the hosts, and only the focused one renders them, so
// neither ever shows twice (the screen below the top one is kept live).
// The composer gets no undo bar: its Undo would hand the open composer
// another draft. A held send's bar shows on the screen below once it closes.
function FocusedHosts({ undo }: { undo: boolean }) {
if (!useIsFocused()) return null;
return (
<>
{undo && <UndoSnackbar />}
<ToastHost />
</>
);
}
function withToastHost({ children }: { children: React.ReactElement }) {
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
return (
<>
{children}
<FocusedToastHost />
<FocusedHosts undo={route.name !== 'Compose'} />
</>
);
}
@@ -709,7 +719,7 @@ export default function App() {
<StatusBar style={statusBarStyle} />
{/* On Fabric, native-stack keeps the screen right below the top live and
freezes the ones further down. */}
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withToastHost}>
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
+12 -11
View File
@@ -6,14 +6,13 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store';
import { getFullEmail, restoreEmailToDraft } from '../api/email';
import { ownMailboxes } from '../lib/mailbox-tree';
import { draftContextFromEmail } from '../lib/draft-context';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -23,6 +22,12 @@ interface Shown {
send?: PendingUndoSend;
}
/**
* The undo bar for the last list action (archive, delete, move, spam) and
* for a send held by the undo-send delay. App.tsx gives every stack screen
* one and only the focused screen's renders, so a reply sent from the viewer
* can be undone there and the bar never shows twice.
*/
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -105,12 +110,8 @@ export function UndoSnackbar() {
if (!ok) return;
// Bring the message back as an editable draft and reopen the composer.
try {
const own = ownMailboxes(useEmailStore.getState().mailboxes);
const drafts = own.find((m) => m.role === 'drafts');
const sent = own.find((m) => m.role === 'sent');
if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id);
const email = await getFullEmail(send.emailId);
navigation.navigate('Compose', { draft: draftContextFromEmail(email) });
const draft = await restoreUndoneSend(send, useEmailStore.getState().mailboxes);
navigation.navigate('Compose', { draft });
} catch (err) {
console.warn('[send-undo] reopen failed', err);
}
@@ -127,7 +128,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
+13 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert } from 'react-native';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
import { Send, Maximize2 } from 'lucide-react-native';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -8,6 +8,7 @@ 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 { useSendUndoStore } from '../../stores/send-undo-store';
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import { buildReplyRecipients } from '../../lib/reply-recipients';
@@ -88,6 +89,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);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
const result = await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
@@ -105,14 +107,23 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
},
identity.id,
sent.originalId ?? sent.id,
jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId),
holdFor,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
);
// Held for the undo-send delay: the undo bar offers Undo / Send now.
// Recorded before the flag below so its round trip doesn't eat the window.
useSendUndoStore.getState().recordHeldSend(result, holdFor, {
identityId: identity.id,
accountId: jmapAccountId,
from: [{ name: identity.name, email: identity.email }],
});
try {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
} catch { /* the reply is out; the flag is cosmetic */ }
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
setText('');
// The keyboard would cover the undo bar or the toast.
Keyboard.dismiss();
// 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) {
+4 -9
View File
@@ -2151,19 +2151,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
t('email_composer.scheduled_title', 'Scheduled'),
t('email_composer.scheduled_body', 'Your message will be sent at {time}.', { time: formatWhen(when) }),
);
} else if (holdForSeconds && result.scheduled && result.emailSubmissionId && result.emailId) {
// Undo-send window: the mail list's snackbar offers Undo / Send now.
useSendUndoStore.getState().setPending({
emailSubmissionId: result.emailSubmissionId,
emailId: result.emailId,
} else if (result.scheduled) {
// Undo-send window: the undo bar offers Undo / Send now.
useSendUndoStore.getState().recordHeldSend(result, holdForSeconds, {
identityId: primaryIdentity.id,
from: outgoing.from,
to: [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])],
sendAt: result.sendAt,
delaySeconds: holdForSeconds,
createdAt: Date.now(),
});
} else if (!result.scheduled) {
} else {
// Held sends get the undo bar instead (webmail b03a0c1d).
toast.success(t('notifications.email_sent', 'Email sent successfully'));
}
-3
View File
@@ -19,7 +19,6 @@ import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { TagSheet } from '../components/TagSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import {
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
@@ -1721,8 +1720,6 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
selectedEmails={selectedEmails}
onToggle={handleBatchTagToggle}
/>
<UndoSnackbar />
</View>
);
}
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/email', () => ({
cancelScheduledSend: vi.fn(async () => undefined),
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
restoreEmailToDraft: vi.fn(async () => undefined),
getFullEmail: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'primary' } }));
import * as emailApi from '../../api/email';
import type { Email, Mailbox } from '../../api/types';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../send-undo-store';
const api = vi.mocked(emailApi);
const HELD = { scheduled: true, emailSubmissionId: 'sub-1', emailId: 'mail-1', sendAt: '2026-09-24T10:00:10Z' };
const SENDER = { name: 'Me', email: 'me@example.com' };
function mailbox(role: string, id: string, shared?: { accountId: string }): Mailbox {
return {
id: shared ? `${shared.accountId}:${id}` : id,
originalId: shared ? id : undefined,
name: role,
role,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {} as Mailbox['myRights'],
accountId: shared?.accountId,
isShared: !!shared,
};
}
const MAILBOXES = [
mailbox('drafts', 'own-drafts'),
mailbox('sent', 'own-sent'),
mailbox('drafts', 'team-drafts', { accountId: 'team' }),
mailbox('sent', 'team-sent', { accountId: 'team' }),
];
function entry(overrides: Partial<PendingUndoSend> = {}): PendingUndoSend {
return {
emailSubmissionId: 'sub-1',
emailId: 'mail-1',
identityId: 'id-1',
from: [SENDER],
delaySeconds: 10,
createdAt: 1,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
useSendUndoStore.setState({ pending: null, busy: false, restoredEmailId: null });
api.getFullEmail.mockResolvedValue({ id: 'mail-1', subject: 'Re: hi' } as Email);
});
describe('send-undo-store', () => {
describe('recordHeldSend', () => {
it('offers undo for a send held by the delay', () => {
const ok = useSendUndoStore.getState().recordHeldSend(HELD, 10, {
identityId: 'id-1',
accountId: 'team',
from: [SENDER],
});
expect(ok).toBe(true);
expect(useSendUndoStore.getState().pending).toMatchObject({
emailSubmissionId: 'sub-1',
emailId: 'mail-1',
identityId: 'id-1',
accountId: 'team',
from: [SENDER],
sendAt: HELD.sendAt,
delaySeconds: 10,
});
});
it('records nothing for a send that went out at once', () => {
const store = useSendUndoStore.getState();
expect(store.recordHeldSend({ scheduled: false, emailSubmissionId: 'sub-1', emailId: 'mail-1' }, undefined, { identityId: 'id-1' })).toBe(false);
expect(store.recordHeldSend(HELD, undefined, { identityId: 'id-1' })).toBe(false);
expect(store.recordHeldSend(HELD, 0, { identityId: 'id-1' })).toBe(false);
expect(useSendUndoStore.getState().pending).toBeNull();
});
it('records nothing when the server named no submission or message', () => {
const store = useSendUndoStore.getState();
expect(store.recordHeldSend({ ...HELD, emailSubmissionId: undefined }, 10, { identityId: 'id-1' })).toBe(false);
expect(store.recordHeldSend({ ...HELD, emailId: undefined }, 10, { identityId: 'id-1' })).toBe(false);
expect(useSendUndoStore.getState().pending).toBeNull();
});
});
describe('undo and send now', () => {
it('cancels the submission in the account holding it', async () => {
useSendUndoStore.getState().setPending(entry({ accountId: 'team' }));
await expect(useSendUndoStore.getState().undo()).resolves.toBe(true);
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'team');
expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false, restoredEmailId: 'mail-1' });
});
it('cancels an own-account send in the primary account', async () => {
useSendUndoStore.getState().setPending(entry());
await useSendUndoStore.getState().undo();
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', undefined);
});
it('keeps the entry when the cancel fails', async () => {
api.cancelScheduledSend.mockRejectedValueOnce(new Error('gone'));
const pending = entry();
useSendUndoStore.getState().setPending(pending);
await expect(useSendUndoStore.getState().undo()).resolves.toBe(false);
expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false });
});
it('releases the held message from the account holding it', async () => {
useSendUndoStore.getState().setPending(entry({ accountId: 'team' }));
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(true);
expect(api.rescheduleScheduledSend).toHaveBeenCalledWith(
expect.objectContaining({ emailSubmissionId: 'sub-1', emailId: 'mail-1', identityId: 'id-1', accountId: 'team' }),
0,
);
expect(useSendUndoStore.getState().pending).toBeNull();
});
});
describe('restoreUndoneSend', () => {
it('moves an own-account message back into the own Drafts', async () => {
const draft = await restoreUndoneSend(entry(), MAILBOXES);
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', undefined);
expect(api.getFullEmail).toHaveBeenCalledWith('mail-1', undefined);
expect(draft).toMatchObject({ id: 'mail-1', jmapAccountId: undefined });
});
it('moves a shared-account reply back into that account\'s Drafts', async () => {
const draft = await restoreUndoneSend(entry({ accountId: 'team' }), MAILBOXES);
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'team-drafts', 'team-sent', 'team');
expect(api.getFullEmail).toHaveBeenCalledWith('mail-1', 'team');
expect(draft).toMatchObject({ id: 'mail-1', jmapAccountId: 'team' });
});
it('treats the primary account named explicitly as the own account', async () => {
const draft = await restoreUndoneSend(entry({ accountId: 'primary' }), MAILBOXES);
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', undefined);
expect(draft.jmapAccountId).toBeUndefined();
});
it('still opens the message when the account has no Drafts folder', async () => {
const draft = await restoreUndoneSend(entry({ accountId: 'team' }), MAILBOXES.slice(0, 2));
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
expect(draft.id).toBe('mail-1');
});
});
});
+66 -6
View File
@@ -1,17 +1,26 @@
// A message sent with the undo-send delay: the submission is held by the
// server (HOLDFOR) and can still be cancelled or released early. The composer
// records it here after a delayed send; the UndoSnackbar on the mail list
// offers "Undo" / "Send now" for the length of the window (webmail
// `pendingUndoSend`, changelog 1.7.0).
// and the viewer's quick reply record it here after a delayed send; the
// UndoSnackbar, which every screen hosts, offers "Undo" / "Send now" for the
// length of the window (webmail `pendingUndoSend`, changelog 1.7.0).
import { create } from 'zustand';
import { cancelScheduledSend, rescheduleScheduledSend } from '../api/email';
import type { EmailAddress } from '../api/types';
import {
cancelScheduledSend, getFullEmail, rescheduleScheduledSend, restoreEmailToDraft,
type SendEmailResult,
} from '../api/email';
import { jmapClient } from '../api/jmap-client';
import type { EmailAddress, Mailbox } from '../api/types';
import { draftContextFromEmail } from '../lib/draft-context';
import { mailboxesOfAccount } from '../lib/mailbox-tree';
import type { ComposeDraftContext } from '../navigation/types';
export interface PendingUndoSend {
emailSubmissionId: string;
emailId: string;
identityId: string;
/** Account holding the submission: a shared account for a send from its identity. */
accountId?: string;
from?: EmailAddress[];
to?: EmailAddress[];
/** ISO time the server will release the message. */
@@ -21,12 +30,25 @@ export interface PendingUndoSend {
createdAt: number;
}
/** What the sender knows about a held send beyond the server's answer. */
export type HeldSendDetails = Pick<PendingUndoSend, 'identityId' | 'accountId' | 'from' | 'to'>;
interface SendUndoState {
pending: PendingUndoSend | null;
busy: boolean;
/** Set after an undo so the composer can be reopened with the draft. */
restoredEmailId: string | null;
setPending: (entry: PendingUndoSend) => void;
/**
* Offer undo for a send held by the undo-send delay. Records nothing, and
* returns false, when the message went out at once or the server named no
* submission to cancel.
*/
recordHeldSend: (
result: SendEmailResult,
holdForSeconds: number | undefined,
details: HeldSendDetails,
) => boolean;
clear: () => void;
/** Cancel delivery. The message stays in Sent; the caller may re-open it. */
undo: () => Promise<boolean>;
@@ -40,6 +62,18 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
restoredEmailId: null,
setPending: (entry) => set({ pending: entry, restoredEmailId: null }),
recordHeldSend: (result, holdForSeconds, details) => {
if (!holdForSeconds || !result.scheduled || !result.emailSubmissionId || !result.emailId) return false;
get().setPending({
...details,
emailSubmissionId: result.emailSubmissionId,
emailId: result.emailId,
sendAt: result.sendAt,
delaySeconds: holdForSeconds,
createdAt: Date.now(),
});
return true;
},
clear: () => set({ pending: null, busy: false }),
undo: async () => {
@@ -47,7 +81,7 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
if (!entry || get().busy) return false;
set({ busy: true });
try {
await cancelScheduledSend(entry.emailSubmissionId);
await cancelScheduledSend(entry.emailSubmissionId, entry.accountId);
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
return true;
} catch (err) {
@@ -69,6 +103,7 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
identityId: entry.identityId,
from: entry.from,
to: entry.to,
accountId: entry.accountId,
},
0,
);
@@ -81,3 +116,28 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
}
},
}));
/**
* After an undo: move the held message back into Drafts and load it for the
* composer, in the account it was sent from (a quick reply to shared mail
* goes out of the shared account). Webmail `cancelUndoSend`.
*/
export async function restoreUndoneSend(
entry: PendingUndoSend,
mailboxes: Mailbox[],
): Promise<ComposeDraftContext> {
const shared = !!entry.accountId && entry.accountId !== jmapClient.accountId;
const accountId = shared ? entry.accountId : undefined;
const scope = mailboxesOfAccount(mailboxes, accountId);
const drafts = scope.find((m) => m.role === 'drafts');
const sent = scope.find((m) => m.role === 'sent');
if (drafts) {
await restoreEmailToDraft(
entry.emailId,
drafts.originalId ?? drafts.id,
sent ? (sent.originalId ?? sent.id) : undefined,
accountId,
);
}
return draftContextFromEmail(await getFullEmail(entry.emailId, accountId), accountId);
}