diff --git a/App.tsx b/App.tsx index 402eb3d4..81c98102 100644 --- a/App.tsx +++ b/App.tsx @@ -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 { }); } -// 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() ? : 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 && } + + + ); } -function withToastHost({ children }: { children: React.ReactElement }) { +function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) { return ( <> {children} - + ); } @@ -709,7 +719,7 @@ export default function App() { {/* On Fabric, native-stack keeps the screen right below the top live and freezes the ones further down. */} - + diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index 3ac35699..5217ad6c 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -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 }] }, ]} > diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index e57522a7..5860eb00 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -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) { diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index ad1898e5..e0eebb3f 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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')); } diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 7d71bef9..41e60310 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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} /> - - ); } diff --git a/src/stores/__tests__/send-undo-store.test.ts b/src/stores/__tests__/send-undo-store.test.ts new file mode 100644 index 00000000..1a816a74 --- /dev/null +++ b/src/stores/__tests__/send-undo-store.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/src/stores/send-undo-store.ts b/src/stores/send-undo-store.ts index bcf780ce..d12034da 100644 --- a/src/stores/send-undo-store.ts +++ b/src/stores/send-undo-store.ts @@ -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; + 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; @@ -40,6 +62,18 @@ export const useSendUndoStore = create((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((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((set, get) => ({ identityId: entry.identityId, from: entry.from, to: entry.to, + accountId: entry.accountId, }, 0, ); @@ -81,3 +116,28 @@ export const useSendUndoStore = create((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 { + 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); +}