diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index 1f14da76..03e31a2b 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -5,8 +5,9 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore } from '../../stores/settings-store'; +import { useSettingsStore, type SignaturePosition } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing'; export function ComposingSettings() { const c = useColors(); @@ -19,6 +20,13 @@ export function ComposingSettings() { const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords); const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords); const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); + const signaturePosition = useSettingsStore((s) => s.signaturePosition); + const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled); + const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault); + const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled); + const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval); + const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter); const updateSetting = useSettingsStore((s) => s.updateSetting); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); @@ -49,6 +57,41 @@ export function ComposingSettings() { { label: t('settings.email_behavior.send_delay.seconds', '{seconds} seconds', { seconds: 60 }), value: 60 }, ]; + const AUTOSAVE_OPTIONS: { label: string; value: number }[] = [ + { label: t('settings.composer.autosave.30s', 'Every 30 seconds'), value: 30000 }, + { label: t('settings.composer.autosave.1m', 'Every minute'), value: 60000 }, + { label: t('settings.composer.autosave.2m', 'Every 2 minutes'), value: 120000 }, + { label: t('settings.composer.autosave.5m', 'Every 5 minutes'), value: 300000 }, + ]; + + const SIGNATURE_POSITIONS: { label: string; value: SignaturePosition }[] = [ + { label: t('settings.email_behavior.signature_position.above_quote', 'Before quoted text'), value: 'above_quote' }, + { label: t('settings.email_behavior.signature_position.below_quote', 'After quoted text'), value: 'below_quote' }, + ]; + + const segmented = ( + options: { label: string; value: T }[], + current: T, + onPick: (value: T) => void, + ) => ( + + {options.map((opt) => { + const active = current === opt.value; + return ( + onPick(opt.value)} + style={[styles.segment, active && styles.segmentActive]} + > + + {opt.label} + + + ); + })} + + ); + return ( + + updateSetting('plainTextMode', v)} /> + + + + updateSetting('requestReadReceiptDefault', v)} /> + + + + updateSetting('emptySubjectWarningEnabled', v)} /> + + + + {t('settings.email_behavior.signature_position.label', "Signature Position")} + + {t('settings.email_behavior.signature_position.description', "Where to insert your signature in replies and forwards. Above the quoted text reads naturally as a closing for the reply; below keeps the original message contiguous.")} + + {segmented(SIGNATURE_POSITIONS, signaturePosition, (v) => updateSetting('signaturePosition', v))} + + + + updateSetting('signatureSeparatorEnabled', v)} /> + + + + {t('settings.composer.autosave.label', "Auto-save Interval")} + + {t('settings.composer.autosave.description', "How often to save drafts automatically")} + + {segmented(AUTOSAVE_OPTIONS, autoSaveDraftInterval, (v) => updateSetting('autoSaveDraftInterval', v))} + + + + {t('settings.email_behavior.sub_address_delimiter.label', "Sub-Address Delimiter")} + + {t('settings.email_behavior.sub_address_delimiter.description', "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).", { delimiter: subAddressDelimiter })} + + {segmented( + SUPPORTED_SUB_ADDRESS_DELIMITERS.map((d) => ({ label: `user${d}tag`, value: d as string })), + subAddressDelimiter, + (v) => updateSetting('subAddressDelimiter', v), + )} + + {t('settings.email_behavior.send_delay.label', "Undo send / send delay")} {t('settings.email_behavior.send_delay.description_mobile', "Hold outgoing mail for a few seconds so you can cancel it. Requires server support.")} - - {SEND_DELAY_OPTIONS.map((opt) => { - const active = sendDelaySeconds === opt.value; - return ( - updateSetting('sendDelaySeconds', opt.value)} - style={[styles.segment, active && styles.segmentActive]} - > - - {opt.label} - - - ); - })} - + {segmented(SEND_DELAY_OPTIONS, sendDelaySeconds, (v) => updateSetting('sendDelaySeconds', v))} ` lists, parsed on save. */ + replyTo: string; + bcc: string; textSignature: string; + htmlSignature: string; mayDelete: boolean; }; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Stalwart caps signatures at 2047 bytes (webmail identity-form.tsx). +const SIGNATURE_MAX_BYTES = 2047; + +function utf8Bytes(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code < 0x80) n += 1; + else if (code < 0x800) n += 2; + else if (code >= 0xd800 && code <= 0xdbff) { n += 4; i++; } + else n += 3; + } + return n; +} + +function formatAddressList(list: EmailAddress[] | undefined): string { + return (list ?? []).filter((a) => !!a.email).map((a) => formatRecipient(a.name, a.email)).join(', '); +} function toDraft(identity: Identity): DraftIdentity { return { id: identity.id, name: identity.name ?? '', email: identity.email, + replyTo: formatAddressList(identity.replyTo), + bcc: formatAddressList(identity.bcc), textSignature: identity.textSignature ?? '', + htmlSignature: identity.htmlSignature ?? '', mayDelete: identity.mayDelete, }; } function emptyDraft(): DraftIdentity { - return { id: '', name: '', email: '', textSignature: '', mayDelete: true }; + return { id: '', name: '', email: '', replyTo: '', bcc: '', textSignature: '', htmlSignature: '', mayDelete: true }; +} + +/** Parse a comma list into addresses; returns the invalid entries too. */ +function parseAddressList(value: string): { list: EmailAddress[]; invalid: string[] } { + const list: EmailAddress[] = []; + const invalid: string[] = []; + if (!value.trim()) return { list, invalid }; + for (const r of parseRecipientList(value)) { + if (r.group || !isValidEmail(r.email)) { + invalid.push(r.email || r.name || value); + continue; + } + list.push(r.name ? { name: r.name, email: r.email } : { email: r.email }); + } + return { list, invalid }; } export function IdentitySettings() { @@ -56,6 +99,10 @@ export function IdentitySettings() { const loading = useSettingsStore((s) => s.loading); const error = useSettingsStore((s) => s.error); const fetchIdentities = useSettingsStore((s) => s.fetchIdentities); + const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const accountId = jmapClient.isConnected ? jmapClient.accountId : ''; + const preferredId = accountId ? preferredIdentityIds[accountId] : undefined; const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); @@ -69,29 +116,69 @@ export function IdentitySettings() { const openEdit = (i: Identity) => setEditing(toDraft(i)); const closeEditor = () => setEditing(null); + const setAsDefault = (identity: Identity) => { + if (!accountId) return; + const next = { ...preferredIdentityIds }; + if (preferredId === identity.id) delete next[accountId]; + else next[accountId] = identity.id; + updateSetting('preferredIdentityIds', next); + }; + const saveDraft = async () => { if (!editing) return; - const name = editing.name.trim(); + // A display name that carries an address or a line break would produce a + // malformed From on every message sent with the identity. + const name = sanitizeDisplayName(editing.name); const email = editing.email.trim(); - if (!email || !EMAIL_RE.test(email)) { + if (!email || !isValidEmail(email)) { Alert.alert(t('settings.identities.invalid_email_title', "Invalid email"), t('settings.identities.invalid_email', "Enter a valid email address for this identity.")); return; } + const replyTo = parseAddressList(editing.replyTo); + const bcc = parseAddressList(editing.bcc); + const invalid = [...replyTo.invalid, ...bcc.invalid]; + if (invalid.length) { + Alert.alert( + t('settings.identities.invalid_email_title', "Invalid email"), + t('identities.validation_errors.invalid_emails', 'Invalid emails: {emails}', { emails: invalid.join(', ') }), + ); + return; + } + const htmlSignature = sanitizeSignatureHtml(editing.htmlSignature); + if (utf8Bytes(htmlSignature) > SIGNATURE_MAX_BYTES || utf8Bytes(editing.textSignature) > SIGNATURE_MAX_BYTES) { + Alert.alert( + t('identities.form.signature_byte_limit_reached', 'Server limit reached'), + t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { + bytes: Math.max(utf8Bytes(htmlSignature), utf8Bytes(editing.textSignature)), max: SIGNATURE_MAX_BYTES, + }), + ); + return; + } setSaving(true); try { + const patch: Partial = { + name, + replyTo: replyTo.list.length ? replyTo.list : undefined, + bcc: bcc.list.length ? bcc.list : undefined, + textSignature: editing.textSignature, + htmlSignature, + }; if (editing.id) { // JMAP doesn't allow changing `email` on an existing identity, so we // only PATCH the editable fields. The server will reject email // changes; the form disables that input below to make this obvious. await updateIdentity(editing.id, { - name, - textSignature: editing.textSignature, + ...patch, + // Clearing a list needs an explicit null, not an omitted key. + replyTo: patch.replyTo ?? (null as unknown as EmailAddress[]), + bcc: patch.bcc ?? (null as unknown as EmailAddress[]), }); } else { await createIdentity({ - name, + ...patch, email, textSignature: editing.textSignature || undefined, + htmlSignature: htmlSignature || undefined, }); } closeEditor(); @@ -132,6 +219,9 @@ export function IdentitySettings() { ); }; + const htmlBytes = utf8Bytes(editing?.htmlSignature ?? ''); + const textBytes = utf8Bytes(editing?.textSignature ?? ''); + return ( {identity.name || t('settings.identities.no_name', "(no name)")} {identity.email} + {identities.length > 1 && ( + setAsDefault(identity)} + hitSlop={8} + style={styles.identityDelete} + accessibilityRole="button" + accessibilityLabel={t('identities.set_as_primary', 'Set as primary')} + > + + + )} {!identity.mayDelete ? ( {t('settings.identities.primary', "primary")} @@ -204,7 +309,7 @@ export function IdentitySettings() { - + {t('settings.identities.display_name', "Display name")} {t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")} )} - {t('settings.identities.text_signature', "Plain-text signature")} + {t('identities.form.reply_to_label', 'Reply-To (optional)')} + setEditing((d) => (d ? { ...d, replyTo } : d))} + placeholder={t('identities.form.reply_to_placeholder', 'different@example.com')} + placeholderTextColor={c.textMuted} + style={styles.input} + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + /> + {t('identities.form.bcc_label', 'Auto BCC (optional)')} + setEditing((d) => (d ? { ...d, bcc } : d))} + placeholder={t('identities.form.bcc_placeholder', 'archive@example.com')} + placeholderTextColor={c.textMuted} + style={styles.input} + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + /> + {t('identities.form.text_signature_label', "Text Signature")} setEditing((d) => (d ? { ...d, textSignature } : d))} @@ -237,6 +364,23 @@ export function IdentitySettings() { multiline style={[styles.input, styles.bodyInput]} /> + SIGNATURE_MAX_BYTES && styles.hintError]}> + {t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { bytes: textBytes, max: SIGNATURE_MAX_BYTES })} + + {t('identities.form.html_signature_label', "HTML Signature")} + setEditing((d) => (d ? { ...d, htmlSignature } : d))} + placeholder="

Jane Doe
Bulwark Mail

" + placeholderTextColor={c.textMuted} + multiline + autoCapitalize="none" + autoCorrect={false} + style={[styles.input, styles.bodyInput, styles.mono]} + /> + SIGNATURE_MAX_BYTES && styles.hintError]}> + {t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { bytes: htmlBytes, max: SIGNATURE_MAX_BYTES })} +
@@ -303,7 +447,9 @@ function makeStyles(c: ThemePalette) { }, inputDisabled: { opacity: 0.6 }, bodyInput: { minHeight: 100, textAlignVertical: 'top' }, + mono: { fontFamily: 'monospace', fontSize: 13 }, hint: { ...typography.caption, color: c.mutedForeground }, + hintError: { color: c.error }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm, padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border, diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index fe3a7793..b6628d21 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -10,8 +10,8 @@ import { TextInput, View, } from 'react-native'; -import { Download, FileText, Plus, Trash2, Upload, X } from 'lucide-react-native'; -import { SettingsSection } from './settings-section'; +import { Download, FileText, Plus, Star, Trash2, Upload, X } from 'lucide-react-native'; +import { SettingsSection, ToggleSwitch } from './settings-section'; import Button from '../Button'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -37,6 +37,9 @@ export function TemplateSettings() { const [draftName, setDraftName] = useState(''); const [draftSubject, setDraftSubject] = useState(''); const [draftBody, setDraftBody] = useState(''); + const [draftCategory, setDraftCategory] = useState(''); + const [draftFavorite, setDraftFavorite] = useState(false); + const [draftIsHtml, setDraftIsHtml] = useState(false); useEffect(() => { if (!hydrated) void hydrate(); @@ -56,6 +59,9 @@ export function TemplateSettings() { setDraftName(''); setDraftSubject(''); setDraftBody(''); + setDraftCategory(''); + setDraftFavorite(false); + setDraftIsHtml(false); }; const openEdit = (t: EmailTemplate) => { @@ -63,6 +69,9 @@ export function TemplateSettings() { setDraftName(t.name); setDraftSubject(t.subject); setDraftBody(t.body); + setDraftCategory(t.category); + setDraftFavorite(t.isFavorite); + setDraftIsHtml(!!t.isHTML); }; const closeEditor = () => setEditing(null); @@ -74,10 +83,14 @@ export function TemplateSettings() { return; } if (!editing) return; + const fields = { + name, subject: draftSubject, body: draftBody, + category: draftCategory.trim(), isFavorite: draftFavorite, isHTML: draftIsHtml, + }; if (editing.id) { - updateTemplate(editing.id, { name, subject: draftSubject, body: draftBody }); + updateTemplate(editing.id, fields); } else { - addTemplate({ name, subject: draftSubject, body: draftBody }); + addTemplate(fields); } closeEditor(); }; @@ -142,10 +155,13 @@ export function TemplateSettings() { onLongPress={() => confirmDelete(t)} style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]} > + {t.isFavorite && } {t.name} - {!!t.subject && ( - {t.subject} + {!!(t.subject || t.category) && ( + + {[t.category, t.subject].filter(Boolean).join(' · ')} + )} confirmDelete(t)} hitSlop={8} style={styles.listRowDelete} accessibilityRole="button" accessibilityLabel={tr('common.delete', 'Delete')}> @@ -208,6 +224,14 @@ export function TemplateSettings() { placeholderTextColor={c.textMuted} style={styles.input} /> + {tr('settings.templates.category', 'Category')} + {tr('settings.templates.body', 'Body')} + + {tr('settings.templates.placeholders_hint', 'Placeholders: {{recipient_name}}, {{company}}, {{date}}, {{day_of_week}}, {{sender_name}}')} + + + {tr('settings.templates.favorite', 'Favorite')} + + + + {tr('settings.templates.is_html', 'Body is HTML')} + +
@@ -309,6 +344,12 @@ function makeStyles(c: ThemePalette) { paddingHorizontal: spacing.md, paddingVertical: 10, }, bodyInput: { minHeight: 160, textAlignVertical: 'top' }, + hint: { ...typography.caption, color: c.mutedForeground }, + toggleRow: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: spacing.sm, + }, + toggleLabel: { ...typography.body, color: c.text }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm, padding: spacing.lg, diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 49584e89..65c24b0f 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -1,36 +1,61 @@ import React from 'react'; import { - View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert, + View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert, Modal, Platform, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { ArrowLeft, Clock, X } from 'lucide-react-native'; +import { ArrowLeft, Clock, MoreHorizontal } from 'lucide-react-native'; +import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; 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'; +import { jmapClient } from '../api/jmap-client'; +import { useEmailStore } from '../stores/email-store'; +import { useLocaleStore } from '../stores/locale-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { useSendUndoStore } from '../stores/send-undo-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'; import { useColors } from '../theme/colors'; type Props = NativeStackScreenProps; -function recipientLabel(item: ScheduledEmail): string { - const first = item.to?.[0]; - if (!first) return '(no recipient)'; - const name = first.name || first.email; - const extra = (item.to?.length ?? 0) - 1; - return extra > 0 ? `${name} +${extra}` : name; -} - export default function ScheduledScreen({ navigation }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const locale = useLocaleStore((s) => s.locale); + const timeFormat = useSettingsStore((s) => s.timeFormat); + const mailboxes = useEmailStore((s) => s.mailboxes); const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const [cancelling, setCancelling] = React.useState(null); + const [busyId, setBusyId] = React.useState(null); + const [actionsFor, setActionsFor] = React.useState(null); + const [rescheduleFor, setRescheduleFor] = React.useState(null); + const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null); + const customDraftRef = React.useRef(new Date()); + + const formatWhen = React.useCallback( + (iso: string) => formatQuoteDate(iso, timeFormat, locale), + [timeFormat, locale], + ); + + const recipientLabel = (item: ScheduledEmail): string => { + const first = item.to?.[0]; + if (!first) return t('email_composer.no_recipient', '(no recipient)'); + const name = first.name || first.email; + const extra = (item.to?.length ?? 0) - 1; + return extra > 0 ? `${name} +${extra}` : name; + }; const load = React.useCallback(async () => { setLoading(true); @@ -38,76 +63,194 @@ export default function ScheduledScreen({ navigation }: Props) { try { setItems(await listScheduledEmails()); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load scheduled messages'); + setError(e instanceof Error ? e.message : t('email_list.error', 'Error')); } finally { setLoading(false); } - }, []); + }, [t]); React.useEffect(() => { void load(); }, [load]); + const runAction = async (item: ScheduledEmail, action: () => Promise, failTitle: string) => { + setBusyId(item.emailSubmissionId); + try { + await action(); + } catch (e) { + Alert.alert(failTitle, e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }; + + const removeItem = (id: string) => setItems((prev) => prev.filter((i) => i.emailSubmissionId !== id)); + const onCancel = (item: ScheduledEmail) => { Alert.alert( - 'Cancel scheduled send?', - 'The message will not be delivered. A copy stays in your Sent folder.', + 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.'), [ - { text: 'Keep scheduled', style: 'cancel' }, + { text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' }, { - text: 'Cancel send', + text: t('email_list.cancel_scheduled_send', 'Cancel send'), style: 'destructive', onPress: () => { - setCancelling(item.emailSubmissionId); - void (async () => { - try { - await cancelScheduledSend(item.emailSubmissionId); - setItems((prev) => - prev.filter((i) => i.emailSubmissionId !== item.emailSubmissionId), - ); - } catch (e) { - Alert.alert('Cancel failed', e instanceof Error ? e.message : String(e)); - } finally { - setCancelling(null); + void runAction(item, async () => { + await cancelScheduledSend(item.emailSubmissionId); + if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { + useSendUndoStore.getState().clear(); } - })(); + removeItem(item.emailSubmissionId); + }, t('scheduled.cancel_failed', 'Cancel failed')); }, }, ], ); }; - const renderItem = ({ item }: { item: ScheduledEmail }) => ( - - - - - {new Date(item.sendAt).toLocaleString()} + const onSendNow = (item: ScheduledEmail) => { + void runAction(item, async () => { + await rescheduleScheduledSend(item, 0); + if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { + useSendUndoStore.getState().clear(); + } + removeItem(item.emailSubmissionId); + }, t('scheduled.send_now_failed', 'Could not send now')); + }; + + const onReschedule = (item: ScheduledEmail, date: Date) => { + const seconds = Math.ceil((date.getTime() - Date.now()) / 1000); + if (seconds <= 0) { + Alert.alert( + t('email_composer.schedule_past_title', 'Pick a future time'), + t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'), + ); + return; + } + const max = jmapClient.getMaxDelayedSend(); + if (max > 0 && seconds > max) { + Alert.alert( + t('email_composer.schedule_too_late_title', 'Too far ahead'), + t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'), + ); + return; + } + void runAction(item, async () => { + const result = await rescheduleScheduledSend(item, seconds); + const pending = useSendUndoStore.getState().pending; + if (pending?.emailSubmissionId === item.emailSubmissionId) useSendUndoStore.getState().clear(); + setItems((prev) => prev + .map((i) => (i.emailSubmissionId === item.emailSubmissionId + ? { ...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())); + }, t('scheduled.reschedule_failed', 'Reschedule failed')); + }; + + // Cancel the submission, move the message back into Drafts and open it in + // the composer (webmail cancelScheduledEmailForEdit). + const onEdit = (item: ScheduledEmail) => { + void runAction(item, async () => { + await cancelScheduledSend(item.emailSubmissionId); + if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { + useSendUndoStore.getState().clear(); + } + const own = ownMailboxes(mailboxes); + const drafts = own.find((m) => m.role === 'drafts'); + const sent = own.find((m) => m.role === 'sent'); + if (drafts) await restoreEmailToDraft(item.emailId, drafts.id, sent?.id); + const email = await getFullEmail(item.emailId); + removeItem(item.emailSubmissionId); + navigation.replace('Compose', { draft: draftContextFromEmail(email) }); + }, t('scheduled.edit_failed', 'Could not open the message for editing')); + }; + + const reschedulePresets = React.useMemo(() => { + const now = new Date(); + const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000); + const tomorrowMorning = new Date(now); + tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); + tomorrowMorning.setHours(8, 0, 0, 0); + return [ + { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, + { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, + { label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, rescheduleFor]); + + const startCustomPicker = () => { + customDraftRef.current = new Date(Date.now() + 3600 * 1000); + setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date'); + }; + + const confirmCustom = (date: Date) => { + const item = rescheduleFor; + setRescheduleFor(null); + setCustomStage(null); + if (item) onReschedule(item, date); + }; + + const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => { + if (event.type === 'dismissed' || !selected) { + setCustomStage(null); + return; + } + if (Platform.OS === 'ios') { + customDraftRef.current = selected; + return; + } + if (customStage === 'date') { + const d = new Date(customDraftRef.current); + d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate()); + customDraftRef.current = d; + setCustomStage('time'); + return; + } + if (customStage === 'time') { + const d = new Date(customDraftRef.current); + d.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + confirmCustom(d); + } + }; + + const renderItem = ({ item }: { item: ScheduledEmail }) => { + const busy = busyId === item.emailSubmissionId; + return ( + setActionsFor(item)} disabled={busy}> + + + + {formatWhen(item.sendAt)} + + + {item.subject || t('email_composer.no_subject', '(No Subject)')} + + + {t('email_composer.to', 'To')}: {recipientLabel(item)} + + {item.preview ? ( + {item.preview} + ) : null} + + + {busy ? ( + + ) : ( + + )} - - {item.subject || '(no subject)'} - - - To: {recipientLabel(item)} - - {item.preview ? ( - {item.preview} - ) : null} - - onCancel(item)} - style={styles.cancelBtn} - hitSlop={8} - disabled={cancelling === item.emailSubmissionId} - > - {cancelling === item.emailSubmissionId ? ( - - ) : ( - - )} - - ); + ); + }; + + const actionRows = actionsFor ? [ + { label: t('email_viewer.send_now', 'Send now'), onPress: () => onSendNow(actionsFor) }, + { label: t('email_list.reschedule_send', 'Reschedule'), onPress: () => setRescheduleFor(actionsFor) }, + { label: t('common.edit', 'Edit'), onPress: () => onEdit(actionsFor) }, + { label: t('email_list.cancel_scheduled_send', 'Cancel send'), destructive: true, onPress: () => onCancel(actionsFor) }, + ] : []; return ( @@ -115,7 +258,7 @@ export default function ScheduledScreen({ navigation }: Props) { navigation.goBack()} style={styles.headerBtn} hitSlop={8}> - Scheduled + {t('sidebar.scheduled', 'Scheduled')} @@ -127,15 +270,15 @@ export default function ScheduledScreen({ navigation }: Props) { {error} { void load(); }}> - Retry + {t('common.retry', 'Retry')} ) : items.length === 0 ? ( - No scheduled messages + {t('email_list.no_scheduled_emails', 'No scheduled emails')} - Use the clock icon in the composer to send a message later. + {t('email_list.no_scheduled_emails_description', 'Messages scheduled for later will appear here.')} ) : ( @@ -149,6 +292,93 @@ export default function ScheduledScreen({ navigation }: Props) { onRefresh={() => { void load(); }} /> )} + + {/* Per-message actions */} + setActionsFor(null)}> + setActionsFor(null)}> + {}}> + + {actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')} + + {actionRows.map((row) => ( + { setActionsFor(null); row.onPress(); }} + > + {row.label} + + ))} + setActionsFor(null)}> + {t('common.cancel', 'Cancel')} + + + + + + {/* Reschedule presets */} + setRescheduleFor(null)}> + setRescheduleFor(null)}> + {}}> + {t('email_list.reschedule_send', 'Reschedule')} + {reschedulePresets.map((preset) => ( + { + const item = rescheduleFor; + setRescheduleFor(null); + if (item) onReschedule(item, preset.date); + }} + > + + {preset.label} + {formatWhen(preset.date.toISOString())} + + ))} + + {t('email_composer.schedule_custom', 'Pick date & time…')} + + setRescheduleFor(null)}> + {t('common.cancel', 'Cancel')} + + + + + + {customStage !== null && Platform.OS === 'ios' && ( + setCustomStage(null)}> + setCustomStage(null)}> + {}}> + + + setCustomStage(null)}> + {t('common.cancel', 'Cancel')} + + confirmCustom(customDraftRef.current)}> + {t('email_list.reschedule_send', 'Reschedule')} + + + + + + )} + + {customStage !== null && Platform.OS !== 'ios' && ( + + )} ); } @@ -189,8 +419,46 @@ function makeStyles(c: ThemePalette) { subject: { ...typography.bodyMedium, color: c.text }, recipient: { ...typography.caption, color: c.textSecondary }, preview: { ...typography.caption, color: c.textMuted }, - cancelBtn: { + actionBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm, }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + card: { + width: '100%', + maxWidth: 420, + backgroundColor: c.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + padding: spacing.lg, + gap: spacing.xs, + }, + cardTitle: { ...typography.h3, color: c.text, marginBottom: spacing.xs }, + cardRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm + 2, + minHeight: 44, + }, + cardRowLabel: { ...typography.body, color: c.text, flex: 1 }, + cardRowTime: { ...typography.caption, color: c.textMuted }, + cardActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm }, + cardCancel: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs, paddingHorizontal: spacing.md }, + cardCancelText: { ...typography.bodyMedium, color: c.textSecondary }, + cardConfirm: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + backgroundColor: c.primary, + marginTop: spacing.xs, + }, + cardConfirmText: { ...typography.bodyMedium, color: c.primaryForeground }, }); }