From 14d009de32e49fc890226c41c865f5c86017ae83 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 03:26:32 +0100
Subject: [PATCH] feat: open scheduled messages in a read-only view, and send
cancelled ones back to Drafts #84
---
locales/rn/en.json | 3 +
.../compose/ScheduledMessageView.tsx | 168 ++++++++++++++++++
src/lib/__tests__/compose-scheduled.test.ts | 68 +++++++
src/lib/compose-scheduled.ts | 37 ++++
src/screens/ScheduledScreen.tsx | 116 ++++++++----
5 files changed, 358 insertions(+), 34 deletions(-)
create mode 100644 src/components/compose/ScheduledMessageView.tsx
create mode 100644 src/lib/__tests__/compose-scheduled.test.ts
create mode 100644 src/lib/compose-scheduled.ts
diff --git a/locales/rn/en.json b/locales/rn/en.json
index 370bc0b5..983d1aea 100644
--- a/locales/rn/en.json
+++ b/locales/rn/en.json
@@ -690,15 +690,18 @@
"title": "Get notified about new mail"
},
"scheduled": {
+ "actions": "Actions",
"already_sent": "This message was already sent.",
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
"cancel_failed": "Cancel failed",
+ "cancel_to_drafts_body": "The message will not be sent. It goes back to your Drafts.",
"edit_failed": "Could not open the message for editing",
"keep_scheduled": "Keep scheduled",
"no_longer_scheduled": "This message is no longer scheduled.",
"reschedule_failed": "Reschedule failed",
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.",
"reschedule_lost_drafts": "The message is no longer scheduled. It is back in Drafts, where you can send it again.",
+ "send_cancelled": "Send cancelled",
"send_now_failed": "Could not send now"
},
"settings": {
diff --git a/src/components/compose/ScheduledMessageView.tsx b/src/components/compose/ScheduledMessageView.tsx
new file mode 100644
index 00000000..a784bebf
--- /dev/null
+++ b/src/components/compose/ScheduledMessageView.tsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { View, Text, StyleSheet, Pressable, ScrollView, ActivityIndicator } from 'react-native';
+import { ArrowLeft, Clock, Send, Pencil, XCircle } from '../icons';
+import type { Email } from '../../api/types';
+import type { ScheduledEmail } from '../../api/email';
+import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
+import { useColors } from '../../theme/colors';
+import { useLocaleStore } from '../../stores/locale-store';
+import { useSettingsStore } from '../../stores/settings-store';
+import { MessageContent } from '../email/MessageContent';
+
+interface Props {
+ item: ScheduledEmail;
+ /** The full message; null while it loads. */
+ email: Email | null;
+ error: string | null;
+ /** The account to read the message's blobs from: undefined for the own one. */
+ jmapAccountId?: string;
+ busy: boolean;
+ formatWhen: (iso: string) => string;
+ onBack: () => void;
+ onRetry: () => void;
+ onSendNow: () => void;
+ onReschedule: () => void;
+ onEdit: () => void;
+ onCancelSend: () => void;
+}
+
+/**
+ * A scheduled message, read-only, with the banner the webmail shows above
+ * it: when it goes out, and Send now / Reschedule / Edit / Cancel send
+ * (webmail email-viewer.tsx, native #84).
+ */
+export function ScheduledMessageView({
+ item, email, error, jmapAccountId, busy, formatWhen,
+ onBack, onRetry, onSendNow, onReschedule, onEdit, onCancelSend,
+}: Props) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
+ const identities = useSettingsStore((s) => s.identities);
+
+ const actions = [
+ { key: 'send', label: t('email_viewer.send_now', 'Send now'), icon: Send, onPress: onSendNow },
+ { key: 'reschedule', label: t('email_viewer.reschedule_send', 'Reschedule'), icon: Clock, onPress: onReschedule },
+ { key: 'edit', label: t('email_viewer.cancel_and_edit', 'Cancel and edit'), icon: Pencil, onPress: onEdit },
+ { key: 'cancel', label: t('email_viewer.cancel_scheduled_send', 'Cancel send'), icon: XCircle, onPress: onCancelSend, destructive: true },
+ ];
+
+ return (
+
+
+
+
+
+
+ {item.subject || t('email_composer.no_subject', '(No Subject)')}
+
+
+
+
+
+
+
+ {t('email_viewer.scheduled_banner', 'Scheduled to send at {date}', { date: formatWhen(item.sendAt) })}
+
+ {busy && }
+
+
+ {actions.map(({ key, label, icon: Icon, onPress, destructive }) => (
+ [styles.bannerBtn, pressed && styles.bannerBtnPressed, busy && styles.bannerBtnDisabled]}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {error ? (
+
+ {error}
+
+ {t('common.retry', 'Retry')}
+
+
+ ) : !email ? (
+
+
+
+ ) : (
+
+ undefined}
+ onEmailPatched={() => undefined}
+ />
+
+ )}
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ container: { flex: 1, backgroundColor: c.background },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: componentSizes.headerHeight,
+ paddingHorizontal: spacing.lg,
+ borderBottomWidth: 1,
+ borderBottomColor: c.border,
+ gap: spacing.sm,
+ },
+ headerBtn: {
+ width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
+ },
+ headerTitle: { ...typography.h3, color: c.text, flex: 1 },
+ banner: {
+ margin: spacing.md,
+ padding: spacing.md,
+ gap: spacing.sm,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.surface,
+ },
+ bannerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ bannerText: { ...typography.bodyMedium, color: c.text, flex: 1 },
+ bannerActions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
+ bannerBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ minHeight: 36,
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.background,
+ },
+ bannerBtnPressed: { opacity: 0.7 },
+ bannerBtnDisabled: { opacity: 0.5 },
+ bannerBtnText: { ...typography.caption, color: c.text, fontWeight: '600' },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
+ error: { ...typography.body, color: c.error, textAlign: 'center' },
+ retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
+ content: { paddingBottom: spacing.xl },
+ });
+}
diff --git a/src/lib/__tests__/compose-scheduled.test.ts b/src/lib/__tests__/compose-scheduled.test.ts
new file mode 100644
index 00000000..99b0d206
--- /dev/null
+++ b/src/lib/__tests__/compose-scheduled.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { api } = vi.hoisted(() => ({
+ api: {
+ cancelScheduledSend: vi.fn(async () => undefined),
+ restoreEmailToDraft: vi.fn(async () => undefined),
+ },
+}));
+vi.mock('../../api/email', () => api);
+
+import type { Mailbox } from '../../api/types';
+import { cancelScheduledToDrafts, scheduledAccountOverride } from '../compose-scheduled';
+
+function mailbox(role: string, id: string, shared?: string): Mailbox {
+ return {
+ id: shared ? `${shared}:${id}` : id,
+ originalId: shared ? id : undefined,
+ name: role,
+ role,
+ accountId: shared,
+ isShared: !!shared,
+ } as Mailbox;
+}
+
+const MAILBOXES = [
+ mailbox('drafts', 'own-drafts'),
+ mailbox('sent', 'own-sent'),
+ mailbox('drafts', 'team-drafts', 'team'),
+ mailbox('sent', 'team-sent', 'team'),
+];
+
+beforeEach(() => vi.clearAllMocks());
+
+// native #84: "Cancel send" left the message in Sent, where it passed for a
+// sent one; like Edit, it now goes back to Drafts.
+describe('Cancel send of a scheduled message', () => {
+ it('cancels the submission and moves an own message back into the own Drafts', async () => {
+ const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
+ await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toBe(true);
+ expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'own');
+ expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', 'own');
+ });
+
+ it("moves a group account's message back into that account's Drafts", async () => {
+ const item = { accountId: 'team', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
+ await cancelScheduledToDrafts(item, MAILBOXES, 'own');
+ expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'team');
+ expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'team-drafts', 'team-sent', 'team');
+ });
+
+ it('moves nothing when the cancel fails', async () => {
+ api.cancelScheduledSend.mockRejectedValueOnce(new Error('already sent'));
+ const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
+ await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).rejects.toThrow('already sent');
+ expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
+ });
+
+ it('says so when the account has no Drafts folder', async () => {
+ const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
+ await expect(cancelScheduledToDrafts(item, [mailbox('sent', 'own-sent')], 'own')).resolves.toBe(false);
+ expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
+ });
+
+ it('names the own account as no override', () => {
+ expect(scheduledAccountOverride({ accountId: 'own' }, 'own')).toBeUndefined();
+ expect(scheduledAccountOverride({ accountId: 'team' }, 'own')).toBe('team');
+ });
+});
diff --git a/src/lib/compose-scheduled.ts b/src/lib/compose-scheduled.ts
new file mode 100644
index 00000000..3d815f06
--- /dev/null
+++ b/src/lib/compose-scheduled.ts
@@ -0,0 +1,37 @@
+// "Cancel send" and "Edit" of a scheduled message: the submission is
+// cancelled and the message goes back to Drafts, so nothing sits in Sent
+// looking sent (webmail cancelScheduledEmailForEdit, 780b64461). A message
+// held in a shared or group account goes back to that account's Drafts.
+
+import { cancelScheduledSend, restoreEmailToDraft, type ScheduledEmail } from '../api/email';
+import type { Mailbox } from '../api/types';
+import { mailboxesOfAccount } from './mailbox-tree';
+
+/** The account to pass to the mail calls for `item`: undefined for the own one. */
+export function scheduledAccountOverride(item: Pick, ownAccountId: string): string | undefined {
+ return item.accountId !== ownAccountId ? item.accountId : undefined;
+}
+
+/**
+ * Cancel the scheduled send and move the message back into Drafts. Resolves
+ * with whether it is in Drafts now: false when the account has no Drafts
+ * folder, where it stays in Sent. A failed cancel throws and moves nothing.
+ */
+export async function cancelScheduledToDrafts(
+ item: Pick,
+ mailboxes: Mailbox[],
+ ownAccountId: string,
+): Promise {
+ await cancelScheduledSend(item.emailSubmissionId, item.accountId);
+ const scope = mailboxesOfAccount(mailboxes, scheduledAccountOverride(item, ownAccountId));
+ const drafts = scope.find((m) => m.role === 'drafts');
+ const sent = scope.find((m) => m.role === 'sent');
+ if (!drafts) return false;
+ await restoreEmailToDraft(
+ item.emailId,
+ drafts.originalId ?? drafts.id,
+ sent ? (sent.originalId ?? sent.id) : undefined,
+ item.accountId,
+ );
+ return true;
+}
diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx
index dd4115eb..a3f6c770 100644
--- a/src/screens/ScheduledScreen.tsx
+++ b/src/screens/ScheduledScreen.tsx
@@ -9,9 +9,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import {
listScheduledEmails,
- cancelScheduledSend,
rescheduleScheduledSend,
- restoreEmailToDraft,
getFullEmail,
type ScheduledEmail,
} from '../api/email';
@@ -23,7 +21,6 @@ import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
-import { ownMailboxes } from '../lib/mailbox-tree';
import { draftContextFromEmail } from '../lib/draft-context';
import { formatQuoteDate } from '../lib/quote-header';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
@@ -31,6 +28,10 @@ import { useColors } from '../theme/colors';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
+import { cancelScheduledToDrafts, scheduledAccountOverride } from '../lib/compose-scheduled';
+import { useBackWhileFocused } from '../lib/use-back-while-focused';
+import { ScheduledMessageView } from '../components/compose/ScheduledMessageView';
+import type { Email } from '../api/types';
type Props = NativeStackScreenProps;
@@ -50,6 +51,28 @@ export default function ScheduledScreen({ navigation }: Props) {
const [rescheduleFor, setRescheduleFor] = React.useState(null);
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
const customDraftRef = React.useRef(new Date());
+ // The scheduled message open in the read-only view (native #84).
+ const [viewing, setViewing] = React.useState(null);
+ const [viewEmail, setViewEmail] = React.useState(null);
+ const [viewError, setViewError] = React.useState(null);
+ const viewSeq = React.useRef(0);
+ const openView = React.useCallback((item: ScheduledEmail) => {
+ const seq = ++viewSeq.current;
+ setViewing(item);
+ setViewEmail(null);
+ setViewError(null);
+ getFullEmail(item.emailId, scheduledAccountOverride(item, jmapClient.accountId)).then(
+ (email) => { if (viewSeq.current === seq) setViewEmail(email); },
+ (e: unknown) => { if (viewSeq.current === seq) setViewError(e instanceof Error ? e.message : String(e)); },
+ );
+ }, []);
+ const closeView = React.useCallback(() => {
+ viewSeq.current += 1;
+ setViewing(null);
+ setViewEmail(null);
+ setViewError(null);
+ }, []);
+ useBackWhileFocused(!!viewing, closeView);
const formatWhen = React.useCallback(
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
@@ -90,9 +113,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const restored = restoredSubmission(e);
if (restored || rescheduleLostMessage(e)) {
if (restored) {
- setItems((prev) => prev.map((i) => (sameScheduledSend(i, item)
- ? { ...i, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? i.sendAt }
- : i)));
+ updateItem(item, { emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? item.sendAt });
const pending = useSendUndoStore.getState().pending;
if (isPendingSend(pending, item, jmapClient.accountId)) {
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId });
@@ -147,7 +168,17 @@ export default function ScheduledScreen({ navigation }: Props) {
};
// Submission ids repeat across accounts: a row is its account and id.
- const removeItem = (item: ScheduledEmail) => setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
+ const removeItem = (item: ScheduledEmail) => {
+ setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
+ // It is gone from Scheduled: the view of it closes too.
+ setViewing((v) => (v && sameScheduledSend(v, item) ? null : v));
+ };
+ const updateItem = (item: ScheduledEmail, patch: Pick) => {
+ setItems((prev) => prev
+ .map((i) => (sameScheduledSend(i, item) ? { ...i, ...patch } : i))
+ .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
+ setViewing((v) => (v && sameScheduledSend(v, item) ? { ...v, ...patch } : v));
+ };
const clearUndoFor = (item: ScheduledEmail) => {
if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear();
};
@@ -155,7 +186,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const onCancel = (item: ScheduledEmail) => {
Alert.alert(
t('email_list.cancel_scheduled_send', 'Cancel send'),
- t('scheduled.cancel_body', 'The message will not be delivered. A copy stays in your Sent folder.'),
+ t('scheduled.cancel_to_drafts_body', 'The message will not be sent. It goes back to your Drafts.'),
[
{ text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' },
{
@@ -163,9 +194,15 @@ export default function ScheduledScreen({ navigation }: Props) {
style: 'destructive',
onPress: () => {
void runAction(item, async () => {
- await cancelScheduledSend(item.emailSubmissionId, item.accountId);
+ const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item);
removeItem(item);
+ toast.success(
+ t('scheduled.send_cancelled', 'Send cancelled'),
+ inDrafts
+ ? { message: t('notifications.moved_to_mailbox', 'Moved to {mailbox}', { mailbox: t('sidebar.mailboxes.drafts', 'Drafts') }) }
+ : undefined,
+ );
}, t('scheduled.cancel_failed', 'Cancel failed'));
},
},
@@ -202,11 +239,10 @@ export default function ScheduledScreen({ navigation }: Props) {
void runAction(item, async () => {
const result = await rescheduleScheduledSend(item, seconds);
clearUndoFor(item);
- setItems((prev) => prev
- .map((i) => (sameScheduledSend(i, item)
- ? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() }
- : i))
- .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
+ updateItem(item, {
+ emailSubmissionId: result.emailSubmissionId ?? item.emailSubmissionId,
+ sendAt: result.sendAt ?? date.toISOString(),
+ });
warnRefused(result.refusedRecipients);
}, t('scheduled.reschedule_failed', 'Reschedule failed'));
};
@@ -215,26 +251,12 @@ export default function ScheduledScreen({ navigation }: Props) {
// the composer (webmail cancelScheduledEmailForEdit).
const onEdit = (item: ScheduledEmail) => {
void runAction(item, async () => {
- await cancelScheduledSend(item.emailSubmissionId, item.accountId);
- clearUndoFor(item);
// A message held in a shared account goes back to that account's Drafts.
- const shared = item.accountId !== jmapClient.accountId;
- const scope = shared
- ? mailboxes.filter((m) => m.isShared && m.accountId === item.accountId)
- : ownMailboxes(mailboxes);
- const drafts = scope.find((m) => m.role === 'drafts');
- const sent = scope.find((m) => m.role === 'sent');
- if (drafts) {
- await restoreEmailToDraft(
- item.emailId,
- drafts.originalId ?? drafts.id,
- sent ? (sent.originalId ?? sent.id) : undefined,
- item.accountId,
- );
- }
+ await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
+ clearUndoFor(item);
const email = await getFullEmail(item.emailId, item.accountId);
removeItem(item);
- navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) });
+ navigation.replace('Compose', { draft: draftContextFromEmail(email, scheduledAccountOverride(item, jmapClient.accountId)) });
}, t('scheduled.edit_failed', 'Could not open the message for editing'));
};
@@ -294,7 +316,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const renderItem = ({ item }: { item: ScheduledEmail }) => {
const busy = busyId === scheduledSendKey(item);
return (
- setActionsFor(item)} disabled={busy}>
+ openView(item)} disabled={busy}>
@@ -310,13 +332,20 @@ export default function ScheduledScreen({ navigation }: Props) {
{item.preview}
) : null}
-
+ setActionsFor(item)}
+ disabled={busy}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={t('scheduled.actions', 'Actions')}
+ >
{busy ? (
) : (
)}
-
+
);
};
@@ -330,6 +359,23 @@ export default function ScheduledScreen({ navigation }: Props) {
return (
+ {viewing ? (
+ openView(viewing)}
+ onSendNow={() => onSendNow(viewing)}
+ onReschedule={() => setRescheduleFor(viewing)}
+ onEdit={() => onEdit(viewing)}
+ onCancelSend={() => onCancel(viewing)}
+ />
+ ) : (
+ <>
navigation.goBack()}
@@ -375,6 +421,8 @@ export default function ScheduledScreen({ navigation }: Props) {
onRefresh={() => { void load(); }}
/>
)}
+ >
+ )}
{/* Per-message actions */}
setActionsFor(null)}>