mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: signature, read-receipt, empty-subject, autosave and sub-address settings, identity Reply-To/Bcc/HTML signature with a default identity, template category/favourite, and reschedule/send-now/edit on the scheduled screen (#507)
This commit is contained in:
@@ -5,8 +5,9 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
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 { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
|
||||||
|
|
||||||
export function ComposingSettings() {
|
export function ComposingSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -19,6 +20,13 @@ export function ComposingSettings() {
|
|||||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||||
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
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 updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
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 },
|
{ 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 = <T extends string | number>(
|
||||||
|
options: { label: string; value: T }[],
|
||||||
|
current: T,
|
||||||
|
onPick: (value: T) => void,
|
||||||
|
) => (
|
||||||
|
<View style={styles.segmentRow}>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = current === opt.value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={String(opt.value)}
|
||||||
|
onPress={() => onPick(opt.value)}
|
||||||
|
style={[styles.segment, active && styles.segmentActive]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segmentText, active && styles.segmentTextActive]} numberOfLines={1}>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.composer.title', "Composer")}
|
title={t('settings.composer.title', "Composer")}
|
||||||
@@ -61,27 +104,68 @@ export function ComposingSettings() {
|
|||||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")}
|
||||||
|
description={t('settings.email_behavior.plain_text_mode.description', "Disable the rich text editor and send all emails as plain text only, including replies and forwards")}
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.email_behavior.request_read_receipt.label', "Request read receipts by default")}
|
||||||
|
description={t('settings.email_behavior.request_read_receipt.description', "Pre-enable the read-receipt request when composing a new message.")}
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={requestReadReceiptDefault} onChange={(v) => updateSetting('requestReadReceiptDefault', v)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.email_behavior.empty_subject_warning.label', "Empty subject warning")}
|
||||||
|
description={t('settings.email_behavior.empty_subject_warning.description', "Ask for confirmation before sending a message with no subject.")}
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={emptySubjectWarningEnabled} onChange={(v) => updateSetting('emptySubjectWarningEnabled', v)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<View style={styles.subBlock}>
|
||||||
|
<Text style={styles.subLabel}>{t('settings.email_behavior.signature_position.label', "Signature Position")}</Text>
|
||||||
|
<Text style={styles.subDescription}>
|
||||||
|
{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.")}
|
||||||
|
</Text>
|
||||||
|
{segmented(SIGNATURE_POSITIONS, signaturePosition, (v) => updateSetting('signaturePosition', v))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.email_behavior.signature_separator.label', "Signature Delimiter")}
|
||||||
|
description={t('settings.email_behavior.signature_separator.description', 'Prefix the signature with the standard "-- " delimiter line (RFC 3676). Turn off if you\'d rather flow straight from your message into the signature.')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={signatureSeparatorEnabled} onChange={(v) => updateSetting('signatureSeparatorEnabled', v)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<View style={styles.subBlock}>
|
||||||
|
<Text style={styles.subLabel}>{t('settings.composer.autosave.label', "Auto-save Interval")}</Text>
|
||||||
|
<Text style={styles.subDescription}>
|
||||||
|
{t('settings.composer.autosave.description', "How often to save drafts automatically")}
|
||||||
|
</Text>
|
||||||
|
{segmented(AUTOSAVE_OPTIONS, autoSaveDraftInterval, (v) => updateSetting('autoSaveDraftInterval', v))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.subBlock}>
|
||||||
|
<Text style={styles.subLabel}>{t('settings.email_behavior.sub_address_delimiter.label', "Sub-Address Delimiter")}</Text>
|
||||||
|
<Text style={styles.subDescription}>
|
||||||
|
{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 })}
|
||||||
|
</Text>
|
||||||
|
{segmented(
|
||||||
|
SUPPORTED_SUB_ADDRESS_DELIMITERS.map((d) => ({ label: `user${d}tag`, value: d as string })),
|
||||||
|
subAddressDelimiter,
|
||||||
|
(v) => updateSetting('subAddressDelimiter', v),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.subBlock}>
|
<View style={styles.subBlock}>
|
||||||
<Text style={styles.subLabel}>{t('settings.email_behavior.send_delay.label', "Undo send / send delay")}</Text>
|
<Text style={styles.subLabel}>{t('settings.email_behavior.send_delay.label', "Undo send / send delay")}</Text>
|
||||||
<Text style={styles.subDescription}>
|
<Text style={styles.subDescription}>
|
||||||
{t('settings.email_behavior.send_delay.description_mobile', "Hold outgoing mail for a few seconds so you can cancel it. Requires server support.")}
|
{t('settings.email_behavior.send_delay.description_mobile', "Hold outgoing mail for a few seconds so you can cancel it. Requires server support.")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.segmentRow}>
|
{segmented(SEND_DELAY_OPTIONS, sendDelaySeconds, (v) => updateSetting('sendDelaySeconds', v))}
|
||||||
{SEND_DELAY_OPTIONS.map((opt) => {
|
|
||||||
const active = sendDelaySeconds === opt.value;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={opt.value}
|
|
||||||
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
|
|
||||||
style={[styles.segment, active && styles.segmentActive]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
|
|
||||||
{opt.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
@@ -150,6 +234,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Plus, Trash2, X } from 'lucide-react-native';
|
import { Plus, Star, Trash2, X } from 'lucide-react-native';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
|
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
|
||||||
@@ -21,31 +21,74 @@ import {
|
|||||||
deleteIdentity,
|
deleteIdentity,
|
||||||
updateIdentity,
|
updateIdentity,
|
||||||
} from '../../api/identity';
|
} from '../../api/identity';
|
||||||
import type { Identity } from '../../api/types';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
import type { EmailAddress, Identity } from '../../api/types';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients';
|
||||||
|
import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
|
||||||
|
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
|
||||||
|
|
||||||
type DraftIdentity = {
|
type DraftIdentity = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
/** Comma-separated `Name <addr>` lists, parsed on save. */
|
||||||
|
replyTo: string;
|
||||||
|
bcc: string;
|
||||||
textSignature: string;
|
textSignature: string;
|
||||||
|
htmlSignature: string;
|
||||||
mayDelete: boolean;
|
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 {
|
function toDraft(identity: Identity): DraftIdentity {
|
||||||
return {
|
return {
|
||||||
id: identity.id,
|
id: identity.id,
|
||||||
name: identity.name ?? '',
|
name: identity.name ?? '',
|
||||||
email: identity.email,
|
email: identity.email,
|
||||||
|
replyTo: formatAddressList(identity.replyTo),
|
||||||
|
bcc: formatAddressList(identity.bcc),
|
||||||
textSignature: identity.textSignature ?? '',
|
textSignature: identity.textSignature ?? '',
|
||||||
|
htmlSignature: identity.htmlSignature ?? '',
|
||||||
mayDelete: identity.mayDelete,
|
mayDelete: identity.mayDelete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyDraft(): DraftIdentity {
|
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() {
|
export function IdentitySettings() {
|
||||||
@@ -56,6 +99,10 @@ export function IdentitySettings() {
|
|||||||
const loading = useSettingsStore((s) => s.loading);
|
const loading = useSettingsStore((s) => s.loading);
|
||||||
const error = useSettingsStore((s) => s.error);
|
const error = useSettingsStore((s) => s.error);
|
||||||
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
|
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<DraftIdentity | null>(null);
|
const [editing, setEditing] = useState<DraftIdentity | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -69,29 +116,69 @@ export function IdentitySettings() {
|
|||||||
const openEdit = (i: Identity) => setEditing(toDraft(i));
|
const openEdit = (i: Identity) => setEditing(toDraft(i));
|
||||||
const closeEditor = () => setEditing(null);
|
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 () => {
|
const saveDraft = async () => {
|
||||||
if (!editing) return;
|
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();
|
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."));
|
Alert.alert(t('settings.identities.invalid_email_title', "Invalid email"), t('settings.identities.invalid_email', "Enter a valid email address for this identity."));
|
||||||
return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const patch: Partial<Identity> = {
|
||||||
|
name,
|
||||||
|
replyTo: replyTo.list.length ? replyTo.list : undefined,
|
||||||
|
bcc: bcc.list.length ? bcc.list : undefined,
|
||||||
|
textSignature: editing.textSignature,
|
||||||
|
htmlSignature,
|
||||||
|
};
|
||||||
if (editing.id) {
|
if (editing.id) {
|
||||||
// JMAP doesn't allow changing `email` on an existing identity, so we
|
// JMAP doesn't allow changing `email` on an existing identity, so we
|
||||||
// only PATCH the editable fields. The server will reject email
|
// only PATCH the editable fields. The server will reject email
|
||||||
// changes; the form disables that input below to make this obvious.
|
// changes; the form disables that input below to make this obvious.
|
||||||
await updateIdentity(editing.id, {
|
await updateIdentity(editing.id, {
|
||||||
name,
|
...patch,
|
||||||
textSignature: editing.textSignature,
|
// 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 {
|
} else {
|
||||||
await createIdentity({
|
await createIdentity({
|
||||||
name,
|
...patch,
|
||||||
email,
|
email,
|
||||||
textSignature: editing.textSignature || undefined,
|
textSignature: editing.textSignature || undefined,
|
||||||
|
htmlSignature: htmlSignature || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
closeEditor();
|
closeEditor();
|
||||||
@@ -132,6 +219,9 @@ export function IdentitySettings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const htmlBytes = utf8Bytes(editing?.htmlSignature ?? '');
|
||||||
|
const textBytes = utf8Bytes(editing?.textSignature ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.identities.title', "Sending Identities")}
|
title={t('settings.identities.title', "Sending Identities")}
|
||||||
@@ -170,6 +260,21 @@ export function IdentitySettings() {
|
|||||||
<Text style={styles.identityName}>{identity.name || t('settings.identities.no_name', "(no name)")}</Text>
|
<Text style={styles.identityName}>{identity.name || t('settings.identities.no_name', "(no name)")}</Text>
|
||||||
<Text style={styles.identityEmail}>{identity.email}</Text>
|
<Text style={styles.identityEmail}>{identity.email}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{identities.length > 1 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setAsDefault(identity)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.identityDelete}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('identities.set_as_primary', 'Set as primary')}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={16}
|
||||||
|
color={preferredId === identity.id ? c.primary : c.textMuted}
|
||||||
|
fill={preferredId === identity.id ? c.primary : 'transparent'}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
{!identity.mayDelete ? (
|
{!identity.mayDelete ? (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
|
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
|
||||||
@@ -204,7 +309,7 @@ export function IdentitySettings() {
|
|||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
|
||||||
<Text style={styles.fieldLabel}>{t('settings.identities.display_name', "Display name")}</Text>
|
<Text style={styles.fieldLabel}>{t('settings.identities.display_name', "Display name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={editing?.name ?? ''}
|
value={editing?.name ?? ''}
|
||||||
@@ -228,7 +333,29 @@ export function IdentitySettings() {
|
|||||||
{!!editing?.id && (
|
{!!editing?.id && (
|
||||||
<Text style={styles.hint}>{t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")}</Text>
|
<Text style={styles.hint}>{t('settings.identities.email_locked', "JMAP does not allow changing an identity's email; create a new one instead.")}</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.fieldLabel}>{t('settings.identities.text_signature', "Plain-text signature")}</Text>
|
<Text style={styles.fieldLabel}>{t('identities.form.reply_to_label', 'Reply-To (optional)')}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={editing?.replyTo ?? ''}
|
||||||
|
onChangeText={(replyTo) => 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"
|
||||||
|
/>
|
||||||
|
<Text style={styles.fieldLabel}>{t('identities.form.bcc_label', 'Auto BCC (optional)')}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={editing?.bcc ?? ''}
|
||||||
|
onChangeText={(bcc) => 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"
|
||||||
|
/>
|
||||||
|
<Text style={styles.fieldLabel}>{t('identities.form.text_signature_label', "Text Signature")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={editing?.textSignature ?? ''}
|
value={editing?.textSignature ?? ''}
|
||||||
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
|
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
|
||||||
@@ -237,6 +364,23 @@ export function IdentitySettings() {
|
|||||||
multiline
|
multiline
|
||||||
style={[styles.input, styles.bodyInput]}
|
style={[styles.input, styles.bodyInput]}
|
||||||
/>
|
/>
|
||||||
|
<Text style={[styles.hint, textBytes > SIGNATURE_MAX_BYTES && styles.hintError]}>
|
||||||
|
{t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { bytes: textBytes, max: SIGNATURE_MAX_BYTES })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fieldLabel}>{t('identities.form.html_signature_label', "HTML Signature")}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={editing?.htmlSignature ?? ''}
|
||||||
|
onChangeText={(htmlSignature) => setEditing((d) => (d ? { ...d, htmlSignature } : d))}
|
||||||
|
placeholder="<p><b>Jane Doe</b><br>Bulwark Mail</p>"
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
multiline
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={[styles.input, styles.bodyInput, styles.mono]}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, htmlBytes > SIGNATURE_MAX_BYTES && styles.hintError]}>
|
||||||
|
{t('identities.form.signature_byte_counter', '{bytes} / {max} bytes', { bytes: htmlBytes, max: SIGNATURE_MAX_BYTES })}
|
||||||
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.modalActions}>
|
<View style={styles.modalActions}>
|
||||||
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>{t('common.cancel', "Cancel")}</Button>
|
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>{t('common.cancel', "Cancel")}</Button>
|
||||||
@@ -303,7 +447,9 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
inputDisabled: { opacity: 0.6 },
|
inputDisabled: { opacity: 0.6 },
|
||||||
bodyInput: { minHeight: 100, textAlignVertical: 'top' },
|
bodyInput: { minHeight: 100, textAlignVertical: 'top' },
|
||||||
|
mono: { fontFamily: 'monospace', fontSize: 13 },
|
||||||
hint: { ...typography.caption, color: c.mutedForeground },
|
hint: { ...typography.caption, color: c.mutedForeground },
|
||||||
|
hintError: { color: c.error },
|
||||||
modalActions: {
|
modalActions: {
|
||||||
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
||||||
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
|
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Download, FileText, Plus, Trash2, Upload, X } from 'lucide-react-native';
|
import { Download, FileText, Plus, Star, Trash2, Upload, X } from 'lucide-react-native';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
@@ -37,6 +37,9 @@ export function TemplateSettings() {
|
|||||||
const [draftName, setDraftName] = useState('');
|
const [draftName, setDraftName] = useState('');
|
||||||
const [draftSubject, setDraftSubject] = useState('');
|
const [draftSubject, setDraftSubject] = useState('');
|
||||||
const [draftBody, setDraftBody] = useState('');
|
const [draftBody, setDraftBody] = useState('');
|
||||||
|
const [draftCategory, setDraftCategory] = useState('');
|
||||||
|
const [draftFavorite, setDraftFavorite] = useState(false);
|
||||||
|
const [draftIsHtml, setDraftIsHtml] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated) void hydrate();
|
if (!hydrated) void hydrate();
|
||||||
@@ -56,6 +59,9 @@ export function TemplateSettings() {
|
|||||||
setDraftName('');
|
setDraftName('');
|
||||||
setDraftSubject('');
|
setDraftSubject('');
|
||||||
setDraftBody('');
|
setDraftBody('');
|
||||||
|
setDraftCategory('');
|
||||||
|
setDraftFavorite(false);
|
||||||
|
setDraftIsHtml(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (t: EmailTemplate) => {
|
const openEdit = (t: EmailTemplate) => {
|
||||||
@@ -63,6 +69,9 @@ export function TemplateSettings() {
|
|||||||
setDraftName(t.name);
|
setDraftName(t.name);
|
||||||
setDraftSubject(t.subject);
|
setDraftSubject(t.subject);
|
||||||
setDraftBody(t.body);
|
setDraftBody(t.body);
|
||||||
|
setDraftCategory(t.category);
|
||||||
|
setDraftFavorite(t.isFavorite);
|
||||||
|
setDraftIsHtml(!!t.isHTML);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = () => setEditing(null);
|
const closeEditor = () => setEditing(null);
|
||||||
@@ -74,10 +83,14 @@ export function TemplateSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
|
const fields = {
|
||||||
|
name, subject: draftSubject, body: draftBody,
|
||||||
|
category: draftCategory.trim(), isFavorite: draftFavorite, isHTML: draftIsHtml,
|
||||||
|
};
|
||||||
if (editing.id) {
|
if (editing.id) {
|
||||||
updateTemplate(editing.id, { name, subject: draftSubject, body: draftBody });
|
updateTemplate(editing.id, fields);
|
||||||
} else {
|
} else {
|
||||||
addTemplate({ name, subject: draftSubject, body: draftBody });
|
addTemplate(fields);
|
||||||
}
|
}
|
||||||
closeEditor();
|
closeEditor();
|
||||||
};
|
};
|
||||||
@@ -142,10 +155,13 @@ export function TemplateSettings() {
|
|||||||
onLongPress={() => confirmDelete(t)}
|
onLongPress={() => confirmDelete(t)}
|
||||||
style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]}
|
style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]}
|
||||||
>
|
>
|
||||||
|
{t.isFavorite && <Star size={14} color={c.primary} fill={c.primary} />}
|
||||||
<View style={styles.listRowText}>
|
<View style={styles.listRowText}>
|
||||||
<Text style={styles.listRowName} numberOfLines={1}>{t.name}</Text>
|
<Text style={styles.listRowName} numberOfLines={1}>{t.name}</Text>
|
||||||
{!!t.subject && (
|
{!!(t.subject || t.category) && (
|
||||||
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
|
<Text style={styles.listRowSubject} numberOfLines={1}>
|
||||||
|
{[t.category, t.subject].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete} accessibilityRole="button" accessibilityLabel={tr('common.delete', 'Delete')}>
|
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete} accessibilityRole="button" accessibilityLabel={tr('common.delete', 'Delete')}>
|
||||||
@@ -208,6 +224,14 @@ export function TemplateSettings() {
|
|||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
<Text style={styles.fieldLabel}>{tr('settings.templates.category', 'Category')}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={draftCategory}
|
||||||
|
onChangeText={setDraftCategory}
|
||||||
|
placeholder={tr('settings.templates.category_placeholder', 'e.g., Work, Personal')}
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
<Text style={styles.fieldLabel}>{tr('settings.templates.body', 'Body')}</Text>
|
<Text style={styles.fieldLabel}>{tr('settings.templates.body', 'Body')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={draftBody}
|
value={draftBody}
|
||||||
@@ -217,6 +241,17 @@ export function TemplateSettings() {
|
|||||||
multiline
|
multiline
|
||||||
style={[styles.input, styles.bodyInput]}
|
style={[styles.input, styles.bodyInput]}
|
||||||
/>
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
{tr('settings.templates.placeholders_hint', 'Placeholders: {{recipient_name}}, {{company}}, {{date}}, {{day_of_week}}, {{sender_name}}')}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<Text style={styles.toggleLabel}>{tr('settings.templates.favorite', 'Favorite')}</Text>
|
||||||
|
<ToggleSwitch checked={draftFavorite} onChange={setDraftFavorite} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<Text style={styles.toggleLabel}>{tr('settings.templates.is_html', 'Body is HTML')}</Text>
|
||||||
|
<ToggleSwitch checked={draftIsHtml} onChange={setDraftIsHtml} />
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.modalActions}>
|
<View style={styles.modalActions}>
|
||||||
<Button variant="outline" size="sm" onPress={closeEditor}>{tr('common.cancel', 'Cancel')}</Button>
|
<Button variant="outline" size="sm" onPress={closeEditor}>{tr('common.cancel', 'Cancel')}</Button>
|
||||||
@@ -309,6 +344,12 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingHorizontal: spacing.md, paddingVertical: 10,
|
paddingHorizontal: spacing.md, paddingVertical: 10,
|
||||||
},
|
},
|
||||||
bodyInput: { minHeight: 160, textAlignVertical: 'top' },
|
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: {
|
modalActions: {
|
||||||
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
|
|||||||
+331
-63
@@ -1,36 +1,61 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert,
|
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert, Modal, Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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 { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import {
|
import {
|
||||||
listScheduledEmails,
|
listScheduledEmails,
|
||||||
cancelScheduledSend,
|
cancelScheduledSend,
|
||||||
|
rescheduleScheduledSend,
|
||||||
|
restoreEmailToDraft,
|
||||||
|
getFullEmail,
|
||||||
type ScheduledEmail,
|
type ScheduledEmail,
|
||||||
} from '../api/email';
|
} 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 { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
||||||
|
|
||||||
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) {
|
export default function ScheduledScreen({ navigation }: Props) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
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<ScheduledEmail[]>([]);
|
const [items, setItems] = React.useState<ScheduledEmail[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [cancelling, setCancelling] = React.useState<string | null>(null);
|
const [busyId, setBusyId] = React.useState<string | null>(null);
|
||||||
|
const [actionsFor, setActionsFor] = React.useState<ScheduledEmail | null>(null);
|
||||||
|
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
|
||||||
|
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||||||
|
const customDraftRef = React.useRef<Date>(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 () => {
|
const load = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -38,76 +63,194 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
try {
|
try {
|
||||||
setItems(await listScheduledEmails());
|
setItems(await listScheduledEmails());
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const runAction = async (item: ScheduledEmail, action: () => Promise<void>, 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) => {
|
const onCancel = (item: ScheduledEmail) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Cancel scheduled send?',
|
t('email_list.cancel_scheduled_send', 'Cancel send'),
|
||||||
'The message will not be delivered. A copy stays in your Sent folder.',
|
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',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
setCancelling(item.emailSubmissionId);
|
void runAction(item, async () => {
|
||||||
void (async () => {
|
await cancelScheduledSend(item.emailSubmissionId);
|
||||||
try {
|
if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) {
|
||||||
await cancelScheduledSend(item.emailSubmissionId);
|
useSendUndoStore.getState().clear();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
})();
|
removeItem(item.emailSubmissionId);
|
||||||
|
}, t('scheduled.cancel_failed', 'Cancel failed'));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: ScheduledEmail }) => (
|
const onSendNow = (item: ScheduledEmail) => {
|
||||||
<View style={styles.row}>
|
void runAction(item, async () => {
|
||||||
<View style={styles.rowMain}>
|
await rescheduleScheduledSend(item, 0);
|
||||||
<View style={styles.sendAtRow}>
|
if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) {
|
||||||
<Clock size={13} color={c.primary} />
|
useSendUndoStore.getState().clear();
|
||||||
<Text style={styles.sendAt}>{new Date(item.sendAt).toLocaleString()}</Text>
|
}
|
||||||
|
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 (
|
||||||
|
<Pressable style={styles.row} onPress={() => setActionsFor(item)} disabled={busy}>
|
||||||
|
<View style={styles.rowMain}>
|
||||||
|
<View style={styles.sendAtRow}>
|
||||||
|
<Clock size={13} color={c.primary} />
|
||||||
|
<Text style={styles.sendAt}>{formatWhen(item.sendAt)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.subject} numberOfLines={1}>
|
||||||
|
{item.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.recipient} numberOfLines={1}>
|
||||||
|
{t('email_composer.to', 'To')}: {recipientLabel(item)}
|
||||||
|
</Text>
|
||||||
|
{item.preview ? (
|
||||||
|
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionBtn}>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal size={18} color={c.textSecondary} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.subject} numberOfLines={1}>
|
|
||||||
{item.subject || '(no subject)'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.recipient} numberOfLines={1}>
|
|
||||||
To: {recipientLabel(item)}
|
|
||||||
</Text>
|
|
||||||
{item.preview ? (
|
|
||||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onCancel(item)}
|
|
||||||
style={styles.cancelBtn}
|
|
||||||
hitSlop={8}
|
|
||||||
disabled={cancelling === item.emailSubmissionId}
|
|
||||||
>
|
|
||||||
{cancelling === item.emailSubmissionId ? (
|
|
||||||
<ActivityIndicator size="small" color={c.error} />
|
|
||||||
) : (
|
|
||||||
<X size={18} color={c.error} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
@@ -115,7 +258,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={c.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>Scheduled</Text>
|
<Text style={styles.headerTitle} numberOfLines={1}>{t('sidebar.scheduled', 'Scheduled')}</Text>
|
||||||
<View style={styles.headerBtn} />
|
<View style={styles.headerBtn} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -127,15 +270,15 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<Text style={styles.error}>{error}</Text>
|
<Text style={styles.error}>{error}</Text>
|
||||||
<Pressable onPress={() => { void load(); }}>
|
<Pressable onPress={() => { void load(); }}>
|
||||||
<Text style={styles.retry}>Retry</Text>
|
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
|
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
|
||||||
<Text style={styles.emptyText}>No scheduled messages</Text>
|
<Text style={styles.emptyText}>{t('email_list.no_scheduled_emails', 'No scheduled emails')}</Text>
|
||||||
<Text style={styles.emptyHint}>
|
<Text style={styles.emptyHint}>
|
||||||
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.')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -149,6 +292,93 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
onRefresh={() => { void load(); }}
|
onRefresh={() => { void load(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Per-message actions */}
|
||||||
|
<Modal visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||||
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
|
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||||
|
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||||
|
</Text>
|
||||||
|
{actionRows.map((row) => (
|
||||||
|
<Pressable
|
||||||
|
key={row.label}
|
||||||
|
style={styles.cardRow}
|
||||||
|
onPress={() => { setActionsFor(null); row.onPress(); }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.cardRowLabel, row.destructive && { color: c.error }]}>{row.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Pressable style={styles.cardCancel} onPress={() => setActionsFor(null)}>
|
||||||
|
<Text style={styles.cardCancelText}>{t('common.cancel', 'Cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Reschedule presets */}
|
||||||
|
<Modal visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||||
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
|
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||||
|
{reschedulePresets.map((preset) => (
|
||||||
|
<Pressable
|
||||||
|
key={preset.label}
|
||||||
|
style={styles.cardRow}
|
||||||
|
onPress={() => {
|
||||||
|
const item = rescheduleFor;
|
||||||
|
setRescheduleFor(null);
|
||||||
|
if (item) onReschedule(item, preset.date);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={16} color={c.textSecondary} />
|
||||||
|
<Text style={styles.cardRowLabel}>{preset.label}</Text>
|
||||||
|
<Text style={styles.cardRowTime}>{formatWhen(preset.date.toISOString())}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Pressable style={styles.cardRow} onPress={startCustomPicker}>
|
||||||
|
<Text style={styles.cardRowLabel}>{t('email_composer.schedule_custom', 'Pick date & time…')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.cardCancel} onPress={() => setRescheduleFor(null)}>
|
||||||
|
<Text style={styles.cardCancelText}>{t('common.cancel', 'Cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{customStage !== null && Platform.OS === 'ios' && (
|
||||||
|
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||||
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={customDraftRef.current}
|
||||||
|
mode="datetime"
|
||||||
|
display="spinner"
|
||||||
|
minimumDate={new Date()}
|
||||||
|
onChange={onCustomPickerChange}
|
||||||
|
/>
|
||||||
|
<View style={styles.cardActions}>
|
||||||
|
<Pressable style={styles.cardCancel} onPress={() => setCustomStage(null)}>
|
||||||
|
<Text style={styles.cardCancelText}>{t('common.cancel', 'Cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.cardConfirm} onPress={() => confirmCustom(customDraftRef.current)}>
|
||||||
|
<Text style={styles.cardConfirmText}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customStage !== null && Platform.OS !== 'ios' && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={customDraftRef.current}
|
||||||
|
mode={customStage === 'time' ? 'time' : 'date'}
|
||||||
|
display="default"
|
||||||
|
minimumDate={customStage === 'date' ? new Date() : undefined}
|
||||||
|
onChange={onCustomPickerChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -189,8 +419,46 @@ function makeStyles(c: ThemePalette) {
|
|||||||
subject: { ...typography.bodyMedium, color: c.text },
|
subject: { ...typography.bodyMedium, color: c.text },
|
||||||
recipient: { ...typography.caption, color: c.textSecondary },
|
recipient: { ...typography.caption, color: c.textSecondary },
|
||||||
preview: { ...typography.caption, color: c.textMuted },
|
preview: { ...typography.caption, color: c.textMuted },
|
||||||
cancelBtn: {
|
actionBtn: {
|
||||||
width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user