feat: open scheduled messages in a read-only view, and send cancelled ones back to Drafts #84

This commit is contained in:
Linus Rath
2026-10-29 03:26:32 +01:00
parent 22f2ac5b29
commit 14d009de32
5 changed files with 358 additions and 34 deletions
+3
View File
@@ -690,15 +690,18 @@
"title": "Get notified about new mail" "title": "Get notified about new mail"
}, },
"scheduled": { "scheduled": {
"actions": "Actions",
"already_sent": "This message was already sent.", "already_sent": "This message was already sent.",
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.", "cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
"cancel_failed": "Cancel failed", "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", "edit_failed": "Could not open the message for editing",
"keep_scheduled": "Keep scheduled", "keep_scheduled": "Keep scheduled",
"no_longer_scheduled": "This message is no longer scheduled.", "no_longer_scheduled": "This message is no longer scheduled.",
"reschedule_failed": "Reschedule failed", "reschedule_failed": "Reschedule failed",
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.", "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.", "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" "send_now_failed": "Could not send now"
}, },
"settings": { "settings": {
@@ -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 (
<View style={styles.container}>
<View style={styles.header}>
<Pressable
onPress={onBack}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{item.subject || t('email_composer.no_subject', '(No Subject)')}
</Text>
</View>
<View style={styles.banner} accessibilityRole="summary">
<View style={styles.bannerRow}>
<Clock size={16} color={c.primary} />
<Text style={styles.bannerText}>
{t('email_viewer.scheduled_banner', 'Scheduled to send at {date}', { date: formatWhen(item.sendAt) })}
</Text>
{busy && <ActivityIndicator size="small" color={c.primary} />}
</View>
<View style={styles.bannerActions}>
{actions.map(({ key, label, icon: Icon, onPress, destructive }) => (
<Pressable
key={key}
onPress={onPress}
disabled={busy}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
style={({ pressed }) => [styles.bannerBtn, pressed && styles.bannerBtnPressed, busy && styles.bannerBtnDisabled]}
>
<Icon size={14} color={destructive ? c.error : c.text} />
<Text style={[styles.bannerBtnText, destructive && { color: c.error }]} numberOfLines={1}>{label}</Text>
</Pressable>
))}
</View>
</View>
{error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable onPress={onRetry}>
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
</Pressable>
</View>
) : !email ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
) : (
<ScrollView contentContainerStyle={styles.content}>
<MessageContent
email={email}
jmapAccountId={jmapAccountId}
identities={identities}
// As in Sent: no read receipt banner for the user's own message.
currentMailboxRole="sent"
active={false}
onAddressPress={() => undefined}
onEmailPatched={() => undefined}
/>
</ScrollView>
)}
</View>
);
}
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 },
});
}
@@ -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');
});
});
+37
View File
@@ -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<ScheduledEmail, 'accountId'>, 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<ScheduledEmail, 'accountId' | 'emailSubmissionId' | 'emailId'>,
mailboxes: Mailbox[],
ownAccountId: string,
): Promise<boolean> {
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;
}
+82 -34
View File
@@ -9,9 +9,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { import {
listScheduledEmails, listScheduledEmails,
cancelScheduledSend,
rescheduleScheduledSend, rescheduleScheduledSend,
restoreEmailToDraft,
getFullEmail, getFullEmail,
type ScheduledEmail, type ScheduledEmail,
} from '../api/email'; } from '../api/email';
@@ -23,7 +21,6 @@ import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useSendUndoStore } from '../stores/send-undo-store'; import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store'; import { toast } from '../stores/toast-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { draftContextFromEmail } from '../lib/draft-context'; import { draftContextFromEmail } from '../lib/draft-context';
import { formatQuoteDate } from '../lib/quote-header'; import { formatQuoteDate } from '../lib/quote-header';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; 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 { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key'; import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule'; 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<RootStackParamList, 'Scheduled'>; type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
@@ -50,6 +51,28 @@ export default function ScheduledScreen({ navigation }: Props) {
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null); const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null); const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
const customDraftRef = React.useRef<Date>(new Date()); const customDraftRef = React.useRef<Date>(new Date());
// The scheduled message open in the read-only view (native #84).
const [viewing, setViewing] = React.useState<ScheduledEmail | null>(null);
const [viewEmail, setViewEmail] = React.useState<Email | null>(null);
const [viewError, setViewError] = React.useState<string | null>(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( const formatWhen = React.useCallback(
(iso: string) => formatQuoteDate(iso, timeFormat, locale), (iso: string) => formatQuoteDate(iso, timeFormat, locale),
@@ -90,9 +113,7 @@ export default function ScheduledScreen({ navigation }: Props) {
const restored = restoredSubmission(e); const restored = restoredSubmission(e);
if (restored || rescheduleLostMessage(e)) { if (restored || rescheduleLostMessage(e)) {
if (restored) { if (restored) {
setItems((prev) => prev.map((i) => (sameScheduledSend(i, item) updateItem(item, { emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? item.sendAt });
? { ...i, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? i.sendAt }
: i)));
const pending = useSendUndoStore.getState().pending; const pending = useSendUndoStore.getState().pending;
if (isPendingSend(pending, item, jmapClient.accountId)) { if (isPendingSend(pending, item, jmapClient.accountId)) {
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId }); 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. // 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<ScheduledEmail, 'emailSubmissionId' | 'sendAt'>) => {
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) => { const clearUndoFor = (item: ScheduledEmail) => {
if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear(); 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) => { const onCancel = (item: ScheduledEmail) => {
Alert.alert( Alert.alert(
t('email_list.cancel_scheduled_send', 'Cancel send'), 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' }, { text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' },
{ {
@@ -163,9 +194,15 @@ export default function ScheduledScreen({ navigation }: Props) {
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
void runAction(item, async () => { void runAction(item, async () => {
await cancelScheduledSend(item.emailSubmissionId, item.accountId); const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item); clearUndoFor(item);
removeItem(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')); }, t('scheduled.cancel_failed', 'Cancel failed'));
}, },
}, },
@@ -202,11 +239,10 @@ export default function ScheduledScreen({ navigation }: Props) {
void runAction(item, async () => { void runAction(item, async () => {
const result = await rescheduleScheduledSend(item, seconds); const result = await rescheduleScheduledSend(item, seconds);
clearUndoFor(item); clearUndoFor(item);
setItems((prev) => prev updateItem(item, {
.map((i) => (sameScheduledSend(i, item) emailSubmissionId: result.emailSubmissionId ?? item.emailSubmissionId,
? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() } sendAt: result.sendAt ?? date.toISOString(),
: i)) });
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
warnRefused(result.refusedRecipients); warnRefused(result.refusedRecipients);
}, t('scheduled.reschedule_failed', 'Reschedule failed')); }, t('scheduled.reschedule_failed', 'Reschedule failed'));
}; };
@@ -215,26 +251,12 @@ export default function ScheduledScreen({ navigation }: Props) {
// the composer (webmail cancelScheduledEmailForEdit). // the composer (webmail cancelScheduledEmailForEdit).
const onEdit = (item: ScheduledEmail) => { const onEdit = (item: ScheduledEmail) => {
void runAction(item, async () => { 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. // A message held in a shared account goes back to that account's Drafts.
const shared = item.accountId !== jmapClient.accountId; await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
const scope = shared clearUndoFor(item);
? 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,
);
}
const email = await getFullEmail(item.emailId, item.accountId); const email = await getFullEmail(item.emailId, item.accountId);
removeItem(item); 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')); }, 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 renderItem = ({ item }: { item: ScheduledEmail }) => {
const busy = busyId === scheduledSendKey(item); const busy = busyId === scheduledSendKey(item);
return ( return (
<Pressable style={styles.row} onPress={() => setActionsFor(item)} disabled={busy}> <Pressable style={styles.row} onPress={() => openView(item)} disabled={busy}>
<View style={styles.rowMain}> <View style={styles.rowMain}>
<View style={styles.sendAtRow}> <View style={styles.sendAtRow}>
<Clock size={13} color={c.primary} /> <Clock size={13} color={c.primary} />
@@ -310,13 +332,20 @@ export default function ScheduledScreen({ navigation }: Props) {
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text> <Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null} ) : null}
</View> </View>
<View style={styles.actionBtn}> <Pressable
style={styles.actionBtn}
onPress={() => setActionsFor(item)}
disabled={busy}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('scheduled.actions', 'Actions')}
>
{busy ? ( {busy ? (
<ActivityIndicator size="small" color={c.primary} /> <ActivityIndicator size="small" color={c.primary} />
) : ( ) : (
<MoreHorizontal size={18} color={c.textSecondary} /> <MoreHorizontal size={18} color={c.textSecondary} />
)} )}
</View> </Pressable>
</Pressable> </Pressable>
); );
}; };
@@ -330,6 +359,23 @@ export default function ScheduledScreen({ navigation }: Props) {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}> <SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
{viewing ? (
<ScheduledMessageView
item={viewing}
email={viewEmail}
error={viewError}
jmapAccountId={scheduledAccountOverride(viewing, jmapClient.accountId)}
busy={busyId === scheduledSendKey(viewing)}
formatWhen={formatWhen}
onBack={closeView}
onRetry={() => openView(viewing)}
onSendNow={() => onSendNow(viewing)}
onReschedule={() => setRescheduleFor(viewing)}
onEdit={() => onEdit(viewing)}
onCancelSend={() => onCancel(viewing)}
/>
) : (
<>
<View style={styles.header}> <View style={styles.header}>
<Pressable <Pressable
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
@@ -375,6 +421,8 @@ export default function ScheduledScreen({ navigation }: Props) {
onRefresh={() => { void load(); }} onRefresh={() => { void load(); }}
/> />
)} )}
</>
)}
{/* Per-message actions */} {/* Per-message actions */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}> <Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>