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)}>