mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
3733 lines
160 KiB
TypeScript
3733 lines
160 KiB
TypeScript
import React from 'react';
|
||
import {
|
||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions, Image,
|
||
} from 'react-native';
|
||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { usePreventRemove } from '@react-navigation/native';
|
||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||
import {
|
||
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
|
||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
||
Users, Tag, Type, Highlighter, Trash2,
|
||
} from '../components/icons';
|
||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import * as DocumentPicker from 'expo-document-picker';
|
||
import * as FileSystem from 'expo-file-system/legacy';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||
import { useColors } from '../theme/colors';
|
||
import { Button, IdentitySheet } from '../components';
|
||
import { TemplateSheet } from '../components/TemplateSheet';
|
||
import RichTextEditor, {
|
||
type RichTextEditorHandle,
|
||
type RichTextSelectionState,
|
||
} from '../components/RichTextEditor';
|
||
import { useEmailStore } from '../stores/email-store';
|
||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||
import { getEmailInitials } from '../lib/avatar-utils';
|
||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||
import { useLayout } from '../lib/use-layout';
|
||
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
||
import { useLocaleStore } from '../stores/locale-store';
|
||
import { useSettingsStore } from '../stores/settings-store';
|
||
import { useHasContacts } from '../lib/capabilities';
|
||
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||
import { useAccountStore } from '../stores/account-store';
|
||
import { useSendUndoStore } from '../stores/send-undo-store';
|
||
import { toast } from '../stores/toast-store';
|
||
import { type EmailTemplate } from '../stores/templates-store';
|
||
import { getIdentities } from '../api/identity';
|
||
import {
|
||
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, SendUnconfirmedError,
|
||
type OutgoingAttachment, type OutgoingEmail,
|
||
} from '../api/email';
|
||
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
|
||
import { pruneEmailSubmissions } from '../api/submissions';
|
||
import { composerSendFailureMessage, partlyRefusedWarning } from '../lib/send-failure-message';
|
||
import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
|
||
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix';
|
||
import { computeReplyThreadingHeaders, generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||
import { escapeHtml, stripDangerousTags } from '../lib/email-html';
|
||
import {
|
||
buildInitialHtml, htmlToPlainText, rewriteInlineImages, extractUserAuthoredText,
|
||
rewriteCidImagesForEditor, replaceInlineImagePlaceholders, sniffImageMime, QUOTED_BLOCK_START,
|
||
} from '../lib/compose-html';
|
||
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
|
||
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
|
||
import {
|
||
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
|
||
} from '../lib/recipients';
|
||
import { toUnicodeEmail } from '../lib/idn';
|
||
import {
|
||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||
} from '../lib/reply-identity';
|
||
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||
import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check';
|
||
import { sendTargetFor } from '../lib/compose-send-account';
|
||
import { findAttachmentKeyword } from '../lib/compose-send-checks';
|
||
import { formatScheduledSendAt, schedulePresetLabel, schedulePresets as webmailSchedulePresets } from '../lib/compose-schedule';
|
||
import { inlineImageType } from '../lib/compose-inline-type';
|
||
import { attachmentFromFileNode } from '../lib/compose-files';
|
||
import { supportsFiles } from '../api/files';
|
||
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
||
import {
|
||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||
} from '../lib/compose-login';
|
||
import {
|
||
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
||
} from '../lib/draft-part-blobs';
|
||
import {
|
||
signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
|
||
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
|
||
plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER, wantsSignatureSeparator,
|
||
} from '../lib/signature-utils';
|
||
import {
|
||
getAutoFilledPlaceholders, getPlaceholdersFromTemplate, substitutePlaceholders, templateBodyToHtml,
|
||
} from '../lib/template-utils';
|
||
import {
|
||
generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH,
|
||
} from '../lib/sub-addressing';
|
||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||
import type { EmailAddress, FileNode, Identity } from '../api/types';
|
||
import type { RootStackParamList } from '../navigation/types';
|
||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||
import ToggleSwitch from '../components/ToggleSwitch';
|
||
import { AttachmentPreviewModal } from '../components/email/AttachmentPreviewModal';
|
||
import { useFilePreview } from '../components/email/use-file-preview';
|
||
|
||
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
||
|
||
/** A recipient chip. Group chips carry their resolved members and no email. */
|
||
interface Recipient {
|
||
name: string;
|
||
email: string;
|
||
group?: { members: Array<{ name?: string; email: string }> };
|
||
}
|
||
|
||
type AttachmentEntry = {
|
||
localId: string;
|
||
name: string;
|
||
type: string;
|
||
size: number;
|
||
uri: string;
|
||
inline: boolean;
|
||
cid?: string;
|
||
blobId?: string;
|
||
uploading: boolean;
|
||
/** 0..1 while uploading, when the transport reports it. */
|
||
progress?: number;
|
||
error?: string;
|
||
abort?: AbortController;
|
||
};
|
||
|
||
type Field = 'to' | 'cc' | 'bcc';
|
||
|
||
const URL_RE = /^https?:\/\/.+/i;
|
||
|
||
// The webmail's 2 x 8 palette, used for text and background colour alike.
|
||
const TEXT_COLORS = [
|
||
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
|
||
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
|
||
];
|
||
|
||
function formatBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function genCid(): string {
|
||
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@bulwark.local`;
|
||
}
|
||
|
||
function genLocalId(): string {
|
||
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
async function readUriAsDataUrl(uri: string, mime: string): Promise<string | null> {
|
||
try {
|
||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
});
|
||
return `data:${mime};base64,${base64}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||
function bytesToBase64(bytes: Uint8Array): string {
|
||
let out = '';
|
||
let i = 0;
|
||
for (; i + 2 < bytes.length; i += 3) {
|
||
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
|
||
}
|
||
if (i < bytes.length) {
|
||
const rem = bytes.length - i;
|
||
const n = (bytes[i] << 16) | (rem === 2 ? bytes[i + 1] << 8 : 0);
|
||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (rem === 2 ? B64[(n >> 6) & 63] : '=') + '=';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function toRecipient(r: { name?: string | null; email?: string | null }): Recipient | null {
|
||
if (!r.email) return null;
|
||
return { name: r.name ?? '', email: r.email };
|
||
}
|
||
|
||
function toRecipientList(list: Array<{ name?: string | null; email?: string | null }> | undefined | null): Recipient[] {
|
||
return (list ?? []).map(toRecipient).filter((r): r is Recipient => !!r);
|
||
}
|
||
|
||
function fromParsed(r: ParsedRecipient): Recipient {
|
||
return { name: r.name ?? '', email: r.email, group: r.group };
|
||
}
|
||
|
||
// A domain typed in Unicode goes out in its ASCII form (webmail #1100).
|
||
function toAddress(r: { name?: string; email: string }): EmailAddress {
|
||
return outgoingAddress(r);
|
||
}
|
||
|
||
function chipIsValid(r: Recipient): boolean {
|
||
return r.group ? r.group.members.length > 0 : isValidEmail(r.email);
|
||
}
|
||
|
||
/** Strip the editor's structural markers before the HTML leaves the device. */
|
||
function stripEditorMarkers(html: string): string {
|
||
return html
|
||
.replace(new RegExp(`\\s${SIGNATURE_RANGE_MARKER}=("[^"]*"|'[^']*')`, 'g'), '')
|
||
.replace(/\sdata-quoted-html=("[^"]*"|'[^']*')/g, '');
|
||
}
|
||
|
||
function quoteLines(text: string): string {
|
||
return text.split('\n').map((l) => `> ${l}`).join('\n');
|
||
}
|
||
|
||
function RecipientChip({
|
||
recipient, invalid, onRemove, onLongPress,
|
||
}: {
|
||
recipient: Recipient;
|
||
invalid?: boolean;
|
||
onRemove: () => void;
|
||
onLongPress?: () => void;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const t = useLocaleStore((s) => s.t);
|
||
const label = recipient.group
|
||
? t('email_composer.group_chip', '{name} ({count})', {
|
||
name: recipient.name || t('contacts.group', 'Group'),
|
||
count: recipient.group.members.length,
|
||
})
|
||
: recipient.name || recipient.email;
|
||
return (
|
||
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
|
||
{recipient.group && <Users size={12} color={c.textSecondary} />}
|
||
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
|
||
{label}
|
||
</Text>
|
||
<Pressable
|
||
onPress={onRemove}
|
||
// A 12 px icon: the touch area reaches about 40 dp around it.
|
||
hitSlop={14}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
|
||
>
|
||
<X size={12} color={invalid ? c.error : c.textMuted} />
|
||
</Pressable>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function initialsOf(name: string, email: string): string {
|
||
return getEmailInitials(name.trim(), email);
|
||
}
|
||
|
||
function SuggestionList({
|
||
suggestions, onPick, onPressIn,
|
||
}: {
|
||
suggestions: RecipientSuggestion[];
|
||
onPick: (s: RecipientSuggestion) => void;
|
||
onPressIn?: () => void;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const t = useLocaleStore((s) => s.t);
|
||
return (
|
||
<View style={styles.suggestionBox}>
|
||
{suggestions.map((s, i) => (
|
||
<Pressable
|
||
key={`${s.group?.id ?? s.email}-${i}`}
|
||
onPressIn={onPressIn}
|
||
onPress={() => onPick(s)}
|
||
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
||
>
|
||
<View style={styles.suggestionAvatar}>
|
||
{s.group
|
||
? <Users size={14} color={c.primary} />
|
||
: <Text style={styles.suggestionAvatarText}>{initialsOf(s.name, s.email)}</Text>}
|
||
</View>
|
||
<View style={styles.suggestionText}>
|
||
<Text style={styles.suggestionName} numberOfLines={1}>
|
||
{s.name || s.email}
|
||
</Text>
|
||
{s.group ? (
|
||
<Text style={styles.suggestionEmail} numberOfLines={1}>
|
||
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
|
||
</Text>
|
||
) : !!s.name && (
|
||
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function AttachmentChip({
|
||
attachment, onRemove, onPress,
|
||
}: {
|
||
attachment: AttachmentEntry;
|
||
onRemove: () => void;
|
||
onPress: () => void;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const t = useLocaleStore((s) => s.t);
|
||
const pct = attachment.progress != null ? Math.round(attachment.progress * 100) : null;
|
||
return (
|
||
<Pressable onPress={onPress} style={styles.attachmentChip} disabled={attachment.uploading}>
|
||
{attachment.uploading ? (
|
||
<ActivityIndicator size="small" color={c.primary} />
|
||
) : attachment.error ? (
|
||
<X size={14} color={c.error} />
|
||
) : attachment.type.startsWith('image/') ? (
|
||
<ImageIcon size={14} color={c.textSecondary} />
|
||
) : (
|
||
<FileText size={14} color={c.textSecondary} />
|
||
)}
|
||
<View style={styles.attachmentMeta}>
|
||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||
{attachment.name}
|
||
</Text>
|
||
<Text style={[styles.attachmentSize, !!attachment.error && { color: c.error }]} numberOfLines={1}>
|
||
{attachment.error
|
||
? attachment.error
|
||
: attachment.uploading
|
||
? pct != null
|
||
? t('email_composer.uploading_pct', 'Uploading {pct}%', { pct })
|
||
: t('email_composer.uploading', 'Uploading...')
|
||
: formatBytes(attachment.size)}
|
||
</Text>
|
||
{attachment.uploading && pct != null && (
|
||
<View style={styles.progressTrack}>
|
||
<View style={[styles.progressFill, { width: `${pct}%` }]} />
|
||
</View>
|
||
)}
|
||
</View>
|
||
<Pressable
|
||
onPress={onRemove}
|
||
hitSlop={8}
|
||
style={styles.attachmentRemove}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={attachment.uploading
|
||
? t('email_composer.upload_cancel', 'Cancel upload')
|
||
: t('email_composer.remove_attachment', 'Remove attachment')}
|
||
>
|
||
<X size={14} color={c.textMuted} />
|
||
</Pressable>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function ToolbarButton({
|
||
active, onPress, icon, disabled, label,
|
||
}: {
|
||
active?: boolean;
|
||
onPress: () => void;
|
||
icon: React.ReactNode;
|
||
disabled?: boolean;
|
||
label?: string;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
hitSlop={4}
|
||
disabled={disabled}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={label}
|
||
accessibilityState={{ selected: !!active, disabled: !!disabled }}
|
||
style={[styles.formatBtn, active && styles.formatBtnActive, disabled && styles.formatBtnDisabled]}
|
||
>
|
||
{icon}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
interface SheetOption {
|
||
label: string;
|
||
destructive?: boolean;
|
||
onPress: () => void;
|
||
}
|
||
|
||
/** Simple action list (Android's Alert caps at three buttons). */
|
||
function OptionsSheet({
|
||
visible, title, options, onClose, cancelLabel,
|
||
}: {
|
||
visible: boolean;
|
||
title?: string;
|
||
options: SheetOption[];
|
||
onClose: () => void;
|
||
cancelLabel: string;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
// An option may open a system picker, which iOS only shows once this
|
||
// modal is gone.
|
||
const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current;
|
||
return (
|
||
<Modal
|
||
supportedOrientations={MODAL_ORIENTATIONS}
|
||
visible={visible}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={onClose}
|
||
onDismiss={afterDismiss.flush}
|
||
>
|
||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
|
||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
||
{options.map((opt) => (
|
||
<Pressable
|
||
key={opt.label}
|
||
style={styles.scheduleRow}
|
||
onPress={() => { onClose(); afterDismiss.run(opt.onPress); }}
|
||
>
|
||
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
|
||
</Pressable>
|
||
))}
|
||
<Pressable style={styles.scheduleCancel} onPress={onClose}>
|
||
<Text style={styles.modalCancelText}>{cancelLabel}</Text>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export default function ComposeScreen({ route, navigation }: Props) {
|
||
const filePreview = useFilePreview();
|
||
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 insets = useSafeAreaInsets();
|
||
// Track the visible keyboard obstruction so the format bar stays above it.
|
||
const kbObstruction = useKeyboardObstruction(insets.bottom, () => editorRef.current?.blur());
|
||
const { shortHeight, isSplit } = useLayout();
|
||
const typingInShortWindow = shortHeight && kbObstruction > 0;
|
||
// When the keyboard is up it covers the bottom safe area, so we don't
|
||
// need to add it on top — pad by whichever is larger.
|
||
// On a tablet the composer is a centred panel that stops PANEL_MARGIN
|
||
// short of the screen bottom; the keyboard covers that much less of it.
|
||
const bottomPad = isSplit
|
||
? Math.max(0, kbObstruction - PANEL_MARGIN)
|
||
: Math.max(kbObstruction, insets.bottom);
|
||
const replyTo = route.params?.replyTo;
|
||
const draft = route.params?.draft;
|
||
const mode = route.params?.mode ?? 'compose';
|
||
const prefillTo = route.params?.prefillTo;
|
||
const prefillCc = route.params?.prefillCc;
|
||
const prefillBcc = route.params?.prefillBcc;
|
||
const prefillSubject = route.params?.prefillSubject;
|
||
const prefillBody = route.params?.prefillBody;
|
||
const prefillAttachments = route.params?.prefillAttachments;
|
||
const isReplyLike = !!replyTo && !draft;
|
||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||
// The login's own Sent and Drafts. A From that belongs to a group or
|
||
// shared account is created, submitted and filed in that account instead
|
||
// (sendTargetFor, #1090); a group's folders must never be picked up here.
|
||
const sentMailbox = React.useMemo(
|
||
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
|
||
[mailboxes],
|
||
);
|
||
// The message is created in Drafts and moved to Sent by the submission's
|
||
// onSuccessUpdateEmail, so a failed send never leaves a fake sent copy (#188).
|
||
const draftsMailbox = React.useMemo(
|
||
() => ownMailboxes(mailboxes).find((m) => m.role === 'drafts'),
|
||
[mailboxes],
|
||
);
|
||
|
||
const [identities, setIdentities] = React.useState<Identity[]>([]);
|
||
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
||
const [sending, setSending] = React.useState(false);
|
||
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
|
||
const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false);
|
||
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
|
||
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
|
||
// can only show one field at a time, so we walk date → time.
|
||
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||
const customDraftRef = React.useRef<Date>(new Date());
|
||
|
||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
|
||
// Per-message format (#1022): seeded from the "plain text only" setting,
|
||
// or the format a reopened draft was written in, and switchable from the
|
||
// toolbar for just this message.
|
||
const plainTextSetting = useSettingsStore((s) => s.plainTextMode);
|
||
const [plainTextMode, setPlainTextMode] = React.useState(() => initialPlainTextMode(draft, plainTextSetting));
|
||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||
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 placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled);
|
||
const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval);
|
||
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
|
||
const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds);
|
||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||
const hasContacts = useHasContacts();
|
||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||
|
||
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
|
||
|
||
// Every address that is "us": the login, the account's primary address and
|
||
// every identity. Reply-all must not send the user a copy, and replying to
|
||
// a self-sent message continues to its original recipients (#703).
|
||
const ownEmails = React.useMemo(() => {
|
||
const out = new Set<string>();
|
||
const active = useAccountStore.getState().getActiveAccount();
|
||
for (const e of [active?.email, active?.username, jmapClient.username]) {
|
||
if (e && e.includes('@')) out.add(e);
|
||
}
|
||
for (const i of identities) if (i.email) out.add(i.email);
|
||
return Array.from(out);
|
||
}, [identities]);
|
||
|
||
const replySource = React.useMemo<ReplySource | undefined>(
|
||
() => (replyTo
|
||
? {
|
||
from: replyTo.from.email ? [replyTo.from] : [],
|
||
replyToAddresses: replyTo.replyToAddresses,
|
||
to: replyTo.to,
|
||
cc: replyTo.cc,
|
||
}
|
||
: undefined),
|
||
[replyTo],
|
||
);
|
||
|
||
const initialTo = React.useMemo<Recipient[]>(() => {
|
||
if (draft) return toRecipientList(draft.to);
|
||
if (!replyTo) return toRecipientList(prefillTo);
|
||
if (mode === 'forward') return [];
|
||
return toRecipientList(
|
||
buildReplyRecipients(replySource, mode === 'replyAll' ? 'replyAll' : 'reply', ownEmails).to,
|
||
);
|
||
// Seeds state once; identity-based refinement happens in the effect below.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [replyTo, draft, mode, prefillTo]);
|
||
|
||
const initialCc = React.useMemo<Recipient[]>(() => {
|
||
if (draft) return toRecipientList(draft.cc);
|
||
if (!replyTo) return toRecipientList(prefillCc);
|
||
if (mode !== 'replyAll') return [];
|
||
return toRecipientList(buildReplyRecipients(replySource, 'replyAll', ownEmails).cc);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [replyTo, draft, mode, prefillCc]);
|
||
|
||
const initialBcc = React.useMemo<Recipient[]>(
|
||
() => (draft ? toRecipientList(draft.bcc) : toRecipientList(prefillBcc)),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[draft],
|
||
);
|
||
|
||
const initialSubject = React.useMemo(() => {
|
||
if (draft) return draft.subject ?? '';
|
||
if (!replyTo) return prefillSubject ?? '';
|
||
// Strip stacked / foreign-language prefixes (AW:, WG:, Re[2]:) before
|
||
// adding the locale's own so the chain doesn't grow on every hop.
|
||
if (mode === 'forward') {
|
||
return buildForwardSubject(replyTo.subject, t('email_composer.prefix.forward', 'Fwd:'));
|
||
}
|
||
return buildReplySubject(replyTo.subject, t('email_composer.prefix.reply', 'Re:'));
|
||
}, [replyTo, draft, mode, prefillSubject, t]);
|
||
|
||
// The quoted HTML with `cid:` images swapped for placeholders that carry
|
||
// the cid; the hydration effect below fetches the blobs (#163/#543).
|
||
const seedHtml = React.useMemo(() => {
|
||
const raw = draft ? draft.htmlBody : replyTo?.htmlBody;
|
||
if (!raw) return { html: undefined as string | undefined, cids: [] as string[] };
|
||
const { html, cids } = rewriteCidImagesForEditor(stripDangerousTags(raw));
|
||
return { html, cids };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Remote images in a quoted original stay blocked in the editor whenever
|
||
// the viewer blocks them; the sent HTML keeps them. Read once: the editor
|
||
// page is built on mount.
|
||
const blockRemoteImages = React.useMemo(() => {
|
||
const settings = useSettingsStore.getState();
|
||
const sender = replyTo?.from.email?.trim().toLowerCase();
|
||
return shouldBlockEditorRemoteImages({
|
||
seedHtml: draft ? draft.htmlBody : replyTo?.htmlBody,
|
||
isDraft: !!draft,
|
||
externalContentPolicy: settings.externalContentPolicy,
|
||
senderTrusted: !!sender && (
|
||
settings.isSenderTrusted(sender)
|
||
|| useContactsStore.getState().trustedSenderEmails.includes(sender)
|
||
),
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const initialBodyHtml = React.useMemo(
|
||
() => {
|
||
if (draft) {
|
||
if (seedHtml.html) return seedHtml.html;
|
||
if (draft.textBody) return `<div>${escapeHtml(draft.textBody).replace(/\r?\n/g, '<br>')}</div>`;
|
||
return '<p><br></p>';
|
||
}
|
||
if (!replyTo) {
|
||
return prefillBody
|
||
? `<div>${escapeHtml(prefillBody).replace(/\r?\n/g, '<br>')}</div><p><br></p>`
|
||
: '<p><br></p>';
|
||
}
|
||
// Quick reply "More options" hands the typed text over as prefillBody;
|
||
// it goes above the quote.
|
||
const typed = prefillBody
|
||
? `<div>${escapeHtml(prefillBody).replace(/\r?\n/g, '<br>')}</div>`
|
||
: '';
|
||
return typed + buildInitialHtml(mode, {
|
||
from: { name: replyTo.from.name, email: replyTo.from.email },
|
||
to: replyTo.to,
|
||
cc: replyTo.cc,
|
||
subject: replyTo.subject,
|
||
body: replyTo.body,
|
||
htmlBody: seedHtml.html,
|
||
receivedAt: replyTo.sentAt ?? replyTo.receivedAt,
|
||
}, { timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown'), labels: quoteLabels });
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[],
|
||
);
|
||
|
||
// Plain-text mode seed: the same quote as text, `> `-prefixed for replies.
|
||
const initialPlainBody = React.useMemo(() => {
|
||
if (draft) return draft.textBody ?? (draft.htmlBody ? htmlToPlainText(stripDangerousTags(draft.htmlBody)) : '');
|
||
if (!replyTo) return prefillBody ?? '';
|
||
const header = buildQuoteHeader({
|
||
mode: mode === 'compose' ? 'reply' : mode,
|
||
email: { from: replyTo.from, subject: replyTo.subject, receivedAt: replyTo.sentAt ?? replyTo.receivedAt },
|
||
timeFormat,
|
||
locale,
|
||
unknownLabel: t('common.unknown', 'Unknown'),
|
||
labels: quoteLabels,
|
||
});
|
||
const body = replyTo.body ?? (replyTo.htmlBody ? htmlToPlainText(stripDangerousTags(replyTo.htmlBody)) : '');
|
||
if (!body) return '';
|
||
if (mode === 'forward') return `\n\n${header.text}\n${body}`;
|
||
return `\n\n${header.text}${quoteLines(body)}`;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Attachments carried over from the original (forward) or the re-opened
|
||
// draft. Blobs are account-scoped: a message in a shared/group account has
|
||
// to be re-uploaded into the user's own account before it can be sent.
|
||
// Inline images referenced from the quoted body are hydrated separately
|
||
// and never shown as file chips.
|
||
const seedAttachments = replyTo?.attachments ?? draft?.attachments;
|
||
const seedOwnerAccountId = replyTo?.jmapAccountId ?? draft?.jmapAccountId;
|
||
const seedCidSet = React.useMemo(() => new Set(seedHtml.cids), [seedHtml.cids]);
|
||
const isSeedInline = React.useCallback(
|
||
(a: { cid?: string; disposition?: string }) =>
|
||
!!a.cid && seedCidSet.has(stripMessageIdBrackets(a.cid)),
|
||
[seedCidSet],
|
||
);
|
||
const initialAttachments = React.useMemo<AttachmentEntry[]>(() => {
|
||
if (!seedAttachments?.length) return [];
|
||
return seedAttachments
|
||
.filter((a) => !!a.blobId && !isSeedInline(a))
|
||
.map((a) => ({
|
||
localId: `seed-${a.blobId}`,
|
||
name: a.name || 'attachment',
|
||
type: a.type || 'application/octet-stream',
|
||
size: a.size ?? 0,
|
||
uri: '',
|
||
inline: false,
|
||
blobId: seedOwnerAccountId ? undefined : a.blobId,
|
||
uploading: !!seedOwnerAccountId,
|
||
}));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
||
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>(initialCc);
|
||
const [bccRecipients, setBccRecipients] = React.useState<Recipient[]>(initialBcc);
|
||
const [ccVisible, setCcVisible] = React.useState(initialCc.length > 0 || initialBcc.length > 0);
|
||
const [bccVisible, setBccVisible] = React.useState(initialBcc.length > 0);
|
||
const [toInput, setToInput] = React.useState('');
|
||
const [ccInput, setCcInput] = React.useState('');
|
||
const [bccInput, setBccInput] = React.useState('');
|
||
const [subject, setSubject] = React.useState(initialSubject);
|
||
const [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
|
||
// What the editor page is built from when it mounts: the initial body, or
|
||
// the converted one after a switch from plain text.
|
||
const [editorSeedHtml, setEditorSeedHtml] = React.useState(initialBodyHtml);
|
||
const [plainBody, setPlainBody] = React.useState(initialPlainBody);
|
||
const plainSelectionRef = React.useRef<{ start: number; end: number } | null>(null);
|
||
const [activeField, setActiveField] = React.useState<Field | null>(null);
|
||
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
|
||
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
|
||
const [subAddressTag, setSubAddressTag] = React.useState('');
|
||
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
|
||
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
||
bold: false, italic: false, underline: false, strikeThrough: false,
|
||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||
alignLeft: false, alignCenter: false, alignRight: false, link: false,
|
||
});
|
||
|
||
const editorRef = React.useRef<RichTextEditorHandle>(null);
|
||
const collapseReplyQuote = isReplyLike && (mode === 'reply' || mode === 'replyAll');
|
||
const quoteToggleLabels = React.useMemo(() => ({
|
||
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
|
||
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
|
||
}), [t]);
|
||
// The label column grows with the system font size: at 1.3 "Betreff"
|
||
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
||
const { fontScale } = useWindowDimensions();
|
||
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
|
||
const subjectRef = React.useRef<TextInput>(null);
|
||
const plainBodyRef = React.useRef<TextInput>(null);
|
||
const [subjectFocused, setSubjectFocused] = React.useState(false);
|
||
|
||
// The composer's ScrollView and its live offset. On Android the editor page
|
||
// hands back the caret-reveal pans it undoes (see `pinVisualViewport` in
|
||
// editor-html); scrolling the composer by the same amount keeps the caret
|
||
// where the pan had put it, without leaving the body drawn shifted up.
|
||
const bodyScrollRef = React.useRef<ScrollView>(null);
|
||
const bodyScrollY = React.useRef(0);
|
||
const onEditorViewportShift = React.useCallback((dy: number) => {
|
||
const y = Math.max(0, bodyScrollY.current + dy);
|
||
bodyScrollY.current = y;
|
||
bodyScrollRef.current?.scrollTo({ y, animated: false });
|
||
}, []);
|
||
const isPickingSuggestion = React.useRef(false);
|
||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||
// until send time. Maps cid → blobId/type/name/size.
|
||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||
// A re-opened draft's attachments and inline images are parts of that
|
||
// draft: their blobIds die with each version a save replaces (#849), and an
|
||
// upload expires once nothing references it. This maps the blobId each
|
||
// entry holds to the matching part of the latest saved version, which
|
||
// `buildOutgoing` sends. Kept out of the attachment state so the dirty
|
||
// snapshot does not change on every save.
|
||
const draftPartBlobsRef = React.useRef(initialDraftPartBlobState());
|
||
|
||
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
||
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
||
const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients);
|
||
const contactsVersion = useContactsStore((s) => s.contacts);
|
||
const recentVersion = useContactsStore((s) => s.recentRecipients);
|
||
|
||
React.useEffect(() => {
|
||
if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id);
|
||
}, [sentMailbox?.id, loadRecentRecipients]);
|
||
|
||
const inputFor = (field: Field | null) =>
|
||
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
|
||
const suggestionQuery = inputFor(activeField);
|
||
const alreadySelected = React.useMemo(
|
||
() => new Set(
|
||
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
||
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
|
||
.map((e) => e.toLowerCase()),
|
||
),
|
||
[toRecipients, ccRecipients, bccRecipients],
|
||
);
|
||
const suggestions = React.useMemo<RecipientSuggestion[]>(() => {
|
||
const q = suggestionQuery.trim();
|
||
if (q.length < 1) return [];
|
||
return getAutocomplete(q, 16)
|
||
.filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase()))
|
||
.slice(0, 8);
|
||
// contactsVersion/recentVersion re-run the lookup when the store loads.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [suggestionQuery, alreadySelected, getAutocomplete, contactsVersion, recentVersion]);
|
||
|
||
const setterFor = (field: Field) =>
|
||
field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients;
|
||
const inputSetterFor = (field: Field) =>
|
||
field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
|
||
|
||
const pickSuggestion = (s: RecipientSuggestion) => {
|
||
isPickingSuggestion.current = true;
|
||
const field = activeField ?? 'to';
|
||
let recipient: Recipient;
|
||
if (s.group) {
|
||
const members = getGroupRecipients(s.group.id)
|
||
.filter((m) => !alreadySelected.has(m.email.toLowerCase()))
|
||
.map((m) => ({ name: m.name || undefined, email: m.email }));
|
||
if (members.length === 0) {
|
||
inputSetterFor(field)('');
|
||
return;
|
||
}
|
||
recipient = { name: s.name, email: '', group: { members } };
|
||
} else {
|
||
recipient = { name: s.name, email: s.email };
|
||
}
|
||
setterFor(field)((prev) => [...prev, recipient]);
|
||
inputSetterFor(field)('');
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const list = await getIdentities();
|
||
if (!cancelled) setIdentities(list);
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
setIdentityError(e instanceof Error ? e.message : 'Failed to load identities');
|
||
}
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// Once the identities are known, recompute the reply recipients so every
|
||
// own alias is dropped from a reply-all (the initial seed only knew the
|
||
// login address). Skipped when the user already edited the fields.
|
||
const recipientsRefinedRef = React.useRef(false);
|
||
React.useEffect(() => {
|
||
if (recipientsRefinedRef.current || identities.length === 0) return;
|
||
if (!isReplyLike || mode === 'forward') return;
|
||
recipientsRefinedRef.current = true;
|
||
const sameList = (a: Recipient[], b: Recipient[]) =>
|
||
a.length === b.length && a.every((r, i) => r.email === b[i].email);
|
||
if (!sameList(toRecipients, initialTo) || !sameList(ccRecipients, initialCc)) return;
|
||
const { to, cc } = buildReplyRecipients(
|
||
replySource,
|
||
mode === 'replyAll' ? 'replyAll' : 'reply',
|
||
ownEmails,
|
||
);
|
||
const nextTo = toRecipientList(to);
|
||
const nextCc = toRecipientList(cc);
|
||
if (!sameList(nextTo, toRecipients)) setToRecipients(nextTo);
|
||
if (!sameList(nextCc, ccRecipients)) {
|
||
setCcRecipients(nextCc);
|
||
if (nextCc.length > 0) setCcVisible(true);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [identities]);
|
||
|
||
// Re-upload attachments that live in another account's blob store (forward
|
||
// from a shared folder) into the user's own account.
|
||
React.useEffect(() => {
|
||
if (!seedOwnerAccountId || !seedAttachments?.length) return;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
for (const a of seedAttachments) {
|
||
if (!a.blobId || isSeedInline(a)) continue;
|
||
const localId = `seed-${a.blobId}`;
|
||
try {
|
||
const buf = await jmapClient.fetchBlobArrayBuffer(a.blobId, a.name, a.type, seedOwnerAccountId);
|
||
const up = await uploadBytesForLogin(ownerLoginRef.current, new Uint8Array(buf), a.type || 'application/octet-stream');
|
||
if (!cancelled) updateAttachment(localId, { blobId: up.blobId, size: up.size, uploading: false });
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
updateAttachment(localId, {
|
||
uploading: false,
|
||
error: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed'),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Hydrate the quoted original's inline images: fetch each cid's blob, show
|
||
// it as a data URL and register the part so it is re-attached inline on
|
||
// send (#163/#543). Cross-account blobs are re-uploaded into our account.
|
||
React.useEffect(() => {
|
||
if (seedHtml.cids.length === 0 || !seedAttachments?.length) return;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const map = new Map<string, string>();
|
||
for (const cid of seedHtml.cids) {
|
||
const att = seedAttachments.find((a) => a.cid && stripMessageIdBrackets(a.cid) === cid && a.blobId);
|
||
if (!att?.blobId) continue;
|
||
try {
|
||
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type, seedOwnerAccountId);
|
||
const bytes = new Uint8Array(buf);
|
||
// Never the part's type as it came: it goes into the data URI and
|
||
// into the part sent with the reply.
|
||
const mime = inlineImageType(att.type, sniffImageMime(bytes));
|
||
map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`);
|
||
let blobId = att.blobId;
|
||
if (seedOwnerAccountId) blobId = (await uploadBytesForLogin(ownerLoginRef.current, bytes, mime)).blobId;
|
||
inlineRegistryRef.current.set(cid, {
|
||
localId: `cid-${cid}`,
|
||
name: att.name || 'image',
|
||
type: mime,
|
||
size: att.size ?? bytes.byteLength,
|
||
uri: '',
|
||
inline: true,
|
||
cid,
|
||
blobId,
|
||
uploading: false,
|
||
});
|
||
} catch (err) {
|
||
console.warn('[compose] inline image hydration failed', err);
|
||
}
|
||
}
|
||
if (cancelled || map.size === 0) return;
|
||
// No change message since the editor loaded = the user has not typed
|
||
// yet, so the editor still holds the seeded body (normalised by the
|
||
// WebView DOM, which is why it can differ from the raw server HTML).
|
||
const untouched = !plainTextMode && latestRef.current.bodyHtml === initialBodyHtml;
|
||
let live = latestRef.current.bodyHtml;
|
||
try {
|
||
live = (await editorRef.current?.getHtml()) ?? live;
|
||
} catch { /* fall back to the last change message */ }
|
||
const next = replaceInlineImagePlaceholders(live, map);
|
||
if (next !== live) {
|
||
setBodyHtml(next);
|
||
editorRef.current?.setHtml(next);
|
||
}
|
||
// The hydrated images are part of the untouched baseline, not an edit.
|
||
if (untouched) {
|
||
// Rebase on the body the composer now holds: comparing it with the
|
||
// raw server HTML made every re-opened draft with inline images look
|
||
// edited, and the autosave replaced (and destroyed) it unasked.
|
||
baselineRef.current = withSnapshotBody(baselineRef.current, next);
|
||
if (lastSavedRef.current) lastSavedRef.current = withSnapshotBody(lastSavedRef.current, next);
|
||
} else {
|
||
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
|
||
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// A new message started while a shared folder is open sends as that
|
||
// mailbox's owner when the user has an identity for it (webmail 129f545a).
|
||
// Read once: it is the folder the message was started from. Own folders
|
||
// resolve to nothing, so they keep the preferred identity below.
|
||
const composeFromAccountEmail = React.useMemo(() => {
|
||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||
return resolveComposeAccountEmail(all, currentMailboxId);
|
||
}, []);
|
||
|
||
// Choose the identity once we know both the loaded identities and the
|
||
// compose context: a re-opened draft keeps the identity it was written
|
||
// with; a new message in a shared folder uses that folder's identity; a
|
||
// reply or forward sends from the own identity the original was delivered
|
||
// to (a reply to our own message, from the one that sent it); with
|
||
// auto-select on, a reply to a catch-all alias on an owned domain takes
|
||
// that address as a From override; otherwise the preferred ("Use as
|
||
// default") identity, else the one matching the active account.
|
||
React.useEffect(() => {
|
||
if (selectedIdentityId || identities.length === 0) return;
|
||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||
// Identities can come from the offline cache before the session is up,
|
||
// when the throwing `accountId` getter would crash the screen.
|
||
const connectedAccountId = jmapClient.connectedAccountId;
|
||
const preferredId = connectedAccountId ? preferredIdentityIds[connectedAccountId] : undefined;
|
||
const defaultIdentity =
|
||
identities.find((i) => i.id === preferredId)
|
||
?? identities.find((i) => i.email.toLowerCase() === activeEmail?.toLowerCase())
|
||
?? identities.find((i) => !i.mayDelete)
|
||
?? identities[0];
|
||
|
||
if (draft) {
|
||
const matched = findDraftIdentityId(identities, draft.from?.[0]);
|
||
setSelectedIdentityId(matched ?? defaultIdentity.id);
|
||
return;
|
||
}
|
||
if (!replyTo) {
|
||
const composeId = findComposeIdentityId(identities, composeFromAccountEmail);
|
||
if (composeId) {
|
||
setSelectedIdentityId(composeId);
|
||
return;
|
||
}
|
||
}
|
||
if (replyTo) {
|
||
// The catch-all From rewrite is opt-in and never used on a forward.
|
||
const resolved = resolveReplyIdentity(identities, replyTo, {
|
||
ownEmails,
|
||
catchAll: autoSelectReplyIdentity && mode !== 'forward',
|
||
matchMode: replyIdentityMatch,
|
||
});
|
||
if (resolved) {
|
||
setSelectedIdentityId(resolved.identityId);
|
||
if (resolved.overrideEmail) {
|
||
setFromOverride({ name: resolved.overrideName ?? '', email: resolved.overrideEmail });
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
setSelectedIdentityId(defaultIdentity.id);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [identities, autoSelectReplyIdentity, replyIdentityMatch, replyTo, draft, selectedIdentityId, preferredIdentityIds]);
|
||
|
||
const primaryIdentity = React.useMemo(() => {
|
||
if (identities.length === 0) return null;
|
||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||
const defaultIdentity = identities.find(
|
||
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
|
||
) ?? identities[0];
|
||
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
||
}, [identities, selectedIdentityId]);
|
||
|
||
// Set once the user picks From by hand: their pick then brings its own
|
||
// signature, also when it has none (webmail identityPickedByUser).
|
||
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
||
// An alias without a signature falls back to the primary identity's,
|
||
// unless the user picked it.
|
||
const signatureIdentity = React.useMemo(
|
||
() => signatureIdentityFor(primaryIdentity, identities, { pickedByUser: identityPickedByUser }),
|
||
[primaryIdentity, identities, identityPickedByUser],
|
||
);
|
||
|
||
const openIdentityPicker = () => {
|
||
if (identities.length <= 1) return;
|
||
setIdentitySheetOpen(true);
|
||
};
|
||
|
||
// ── Recipients ───────────────────────────────────────────────────────
|
||
|
||
// Typed-but-uncommitted text is parsed the same way a paste is; leftovers
|
||
// that are not addresses count as invalid and block Send.
|
||
const typedRecipients = React.useMemo(() => {
|
||
const parseField = (input: string, existing: Recipient[]) => {
|
||
if (!input.trim()) return { valid: [] as Recipient[], invalid: [] as string[] };
|
||
const { valid, invalid } = splitPastedRecipients(input, Array.from(alreadySelected));
|
||
return {
|
||
valid: valid.map(fromParsed).filter((r) => !existing.some((e) => e.email.toLowerCase() === r.email.toLowerCase())),
|
||
invalid,
|
||
};
|
||
};
|
||
return {
|
||
to: parseField(toInput, toRecipients),
|
||
cc: parseField(ccInput, ccRecipients),
|
||
bcc: parseField(bccInput, bccRecipients),
|
||
};
|
||
}, [toInput, ccInput, bccInput, toRecipients, ccRecipients, bccRecipients, alreadySelected]);
|
||
|
||
const finalTo = React.useMemo(() => [...toRecipients, ...typedRecipients.to.valid], [toRecipients, typedRecipients]);
|
||
const finalCc = React.useMemo(() => [...ccRecipients, ...typedRecipients.cc.valid], [ccRecipients, typedRecipients]);
|
||
const finalBcc = React.useMemo(() => [...bccRecipients, ...typedRecipients.bcc.valid], [bccRecipients, typedRecipients]);
|
||
const invalidTyped = [...typedRecipients.to.invalid, ...typedRecipients.cc.invalid, ...typedRecipients.bcc.invalid];
|
||
|
||
// Move what was typed into chips (blur / submit). Unparseable leftovers
|
||
// stay in the input so nothing the user typed is silently dropped.
|
||
const addTyped = (field?: Field) => {
|
||
const fields: Field[] = field ? [field] : ['to', 'cc', 'bcc'];
|
||
for (const f of fields) {
|
||
const parsed = typedRecipients[f];
|
||
if (parsed.valid.length) {
|
||
setterFor(f)((prev) => {
|
||
const existing = new Set(prev.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email])).map((e) => e.toLowerCase()));
|
||
const unique = parsed.valid.filter((r) => !existing.has(r.email.toLowerCase()));
|
||
return unique.length ? [...prev, ...unique] : prev;
|
||
});
|
||
}
|
||
const leftover = parsed.invalid.join(' ');
|
||
if (leftover !== inputFor(f)) inputSetterFor(f)(leftover);
|
||
}
|
||
};
|
||
|
||
const [chipMenu, setChipMenu] = React.useState<{ field: Field; index: number } | null>(null);
|
||
const chipMenuRecipient = chipMenu ? (chipMenu.field === 'to' ? toRecipients : chipMenu.field === 'cc' ? ccRecipients : bccRecipients)[chipMenu.index] : null;
|
||
const removeChip = (field: Field, index: number) =>
|
||
setterFor(field)((prev) => prev.filter((_, i) => i !== index));
|
||
const moveChip = (from: Field, index: number, to: Field) => {
|
||
const list = from === 'to' ? toRecipients : from === 'cc' ? ccRecipients : bccRecipients;
|
||
const r = list[index];
|
||
if (!r) return;
|
||
removeChip(from, index);
|
||
setterFor(to)((prev) => [...prev, r]);
|
||
if (to === 'cc') setCcVisible(true);
|
||
if (to === 'bcc') { setCcVisible(true); setBccVisible(true); }
|
||
};
|
||
const chipMenuOptions = React.useMemo<SheetOption[]>(() => {
|
||
if (!chipMenu || !chipMenuRecipient) return [];
|
||
const r = chipMenuRecipient;
|
||
const fieldLabel = (f: Field) => (f === 'to' ? t('email_composer.to', 'To') : f === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.bcc', 'Bcc'));
|
||
const opts: SheetOption[] = [];
|
||
for (const target of (['to', 'cc', 'bcc'] as Field[]).filter((f) => f !== chipMenu.field)) {
|
||
opts.push({
|
||
label: t('email_composer.recipient_move_to', 'Move to {field}', { field: fieldLabel(target) }),
|
||
onPress: () => moveChip(chipMenu.field, chipMenu.index, target),
|
||
});
|
||
}
|
||
if (!r.group) {
|
||
opts.push({
|
||
label: t('email_composer.recipient_copy', 'Copy address'),
|
||
onPress: () => { void Clipboard.setStringAsync(r.email); },
|
||
});
|
||
opts.push({
|
||
label: t('email_composer.recipient_add_contact', 'Add to contacts'),
|
||
onPress: () => navigation.navigate('ContactForm', { prefill: { email: r.email, name: r.name || undefined } }),
|
||
});
|
||
}
|
||
opts.push({
|
||
label: t('email_composer.recipient_remove', 'Remove'),
|
||
destructive: true,
|
||
onPress: () => removeChip(chipMenu.field, chipMenu.index),
|
||
});
|
||
return opts;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [chipMenu, chipMenuRecipient, t]);
|
||
|
||
const hasUploadInFlight = attachments.some((a) => a.uploading);
|
||
const hasUploadError = attachments.some((a) => !!a.error);
|
||
const allChipsValid = [...finalTo, ...finalCc, ...finalBcc].every(chipIsValid);
|
||
const hasValidRecipients = finalTo.length > 0 && allChipsValid && invalidTyped.length === 0;
|
||
const bodyPlain = React.useMemo(
|
||
() => (plainTextMode ? plainBody : htmlToPlainText(bodyHtml)),
|
||
[plainTextMode, plainBody, bodyHtml],
|
||
);
|
||
const hasBodyContent = bodyPlain.trim().length > 0
|
||
|| attachments.some((a) => a.blobId && !a.error);
|
||
const canSend =
|
||
!sending &&
|
||
!hasUploadInFlight &&
|
||
!hasUploadError &&
|
||
hasValidRecipients &&
|
||
hasBodyContent &&
|
||
!!primaryIdentity &&
|
||
!!sentMailbox;
|
||
|
||
// ── Dirty tracking / drafts ──────────────────────────────────────────
|
||
|
||
const snapshotOf = (s: {
|
||
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
|
||
attachments: AttachmentEntry[];
|
||
}) => JSON.stringify({
|
||
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||
bcc: s.bcc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||
subject: s.subject,
|
||
body: s.body,
|
||
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
|
||
});
|
||
|
||
// Memoized: the snapshot copies the whole body, and the screen re-renders
|
||
// for plenty that doesn't touch it (PF8).
|
||
const currentSnapshot = React.useMemo(() => snapshotOf({
|
||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||
body: plainTextMode ? plainBody : bodyHtml, attachments,
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]);
|
||
const baselineRef = React.useRef(snapshotOf({
|
||
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
|
||
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
|
||
}));
|
||
// Snapshot at the last successful draft save (null = never saved).
|
||
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
|
||
const draftIdRef = React.useRef<string | null>(draft?.id ?? null);
|
||
// The account `draftIdRef` lives in, undefined for the login's own: a
|
||
// group identity's draft lives in the group's Drafts (#1090), and a send
|
||
// undone or a scheduled one cancelled for editing comes back in the
|
||
// account it was sent from. Email ids repeat across accounts, so the old
|
||
// version is destroyed there and nowhere else.
|
||
const draftAccountRef = React.useRef<string | undefined>(draft?.jmapAccountId);
|
||
const draftAccount = () => draftAccountRef.current ?? jmapClient.accountId;
|
||
const messageIdRef = React.useRef<string | null>(draft?.messageId?.[0] ?? null);
|
||
const inflightSaveRef = React.useRef<Promise<string | null> | null>(null);
|
||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const lastSaveAtRef = React.useRef(0);
|
||
const allowLeaveRef = React.useRef(false);
|
||
const sendingRef = React.useRef(false);
|
||
// The login this message was started in (see lib/draft-login).
|
||
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
|
||
const loginMoved = () => loginMovedFrom(ownerLoginRef.current);
|
||
/** The address of the login this message was started in, for the "switch back" alerts. */
|
||
const ownerAccountLabel = () => {
|
||
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
|
||
return owner?.email || owner?.displayName || '';
|
||
};
|
||
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
|
||
const [savingDraft, setSavingDraft] = React.useState(false);
|
||
|
||
const [, bumpBaseline] = React.useReducer((n: number) => n + 1, 0);
|
||
const isDirty = currentSnapshot !== baselineRef.current;
|
||
const needsSave = currentSnapshot !== (lastSavedRef.current ?? baselineRef.current);
|
||
|
||
// Latest state for callbacks that outlive a render (autosave timer,
|
||
// beforeRemove listener, hydration).
|
||
const latestRef = React.useRef({ bodyHtml, plainBody, isDirty, needsSave, currentSnapshot });
|
||
latestRef.current = { bodyHtml, plainBody, isDirty, needsSave, currentSnapshot };
|
||
|
||
// ── Signature ────────────────────────────────────────────────────────
|
||
|
||
const placeSignatureHtml = React.useCallback((html: string, sigHtml: string): string => {
|
||
if (!sigHtml) return html;
|
||
if (isReplyLike && signaturePosition === 'above_quote') {
|
||
return insertSignatureAboveQuote(html, sigHtml, QUOTED_BLOCK_START);
|
||
}
|
||
return `${html}${sigHtml}`;
|
||
}, [isReplyLike, signaturePosition]);
|
||
|
||
const placeSignaturePlain = React.useCallback((body: string, identity: Identity): string => {
|
||
const sig = getPlainTextSignature(identity);
|
||
if (!sig) return body;
|
||
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, identity) ? '\n\n-- \n' : '\n\n';
|
||
if (isReplyLike && signaturePosition === 'above_quote') {
|
||
const idx = body.indexOf('\n\n');
|
||
const head = idx === -1 ? body : body.slice(0, idx);
|
||
const tail = idx === -1 ? '' : body.slice(idx);
|
||
return `${head}${sep}${sig}${tail}`;
|
||
}
|
||
return appendPlainTextSignature(body, identity, { separator: signatureSeparatorEnabled });
|
||
}, [isReplyLike, signaturePosition, signatureSeparatorEnabled]);
|
||
|
||
// A reply opens with the caret in the body, where the user's text goes
|
||
// (webmail email-composer.tsx handleEditorReady): in front of the
|
||
// signature and the quote. Again once the signature is in, since putting
|
||
// it in replaces the editor's content and drops the caret.
|
||
const focusReplyBody = React.useCallback(() => {
|
||
if (!isReplyLike || (mode !== 'reply' && mode !== 'replyAll')) return;
|
||
if (plainTextMode) {
|
||
plainBodyRef.current?.focus();
|
||
plainBodyRef.current?.setSelection(0, 0);
|
||
} else {
|
||
editorRef.current?.focusEnd();
|
||
}
|
||
}, [isReplyLike, mode, plainTextMode]);
|
||
React.useEffect(() => {
|
||
focusReplyBody();
|
||
// On open only.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Embed the signature once the identities are known. A re-opened draft
|
||
// keeps whatever it already carries (#848).
|
||
const signatureEmbeddedRef = React.useRef(false);
|
||
const prevSignatureIdentityRef = React.useRef<Identity | null>(null);
|
||
React.useEffect(() => {
|
||
if (!primaryIdentity) return;
|
||
if (!signatureEmbeddedRef.current) {
|
||
signatureEmbeddedRef.current = true;
|
||
prevSignatureIdentityRef.current = signatureIdentity;
|
||
if (draft || !signatureIdentity) return;
|
||
if (plainTextMode) {
|
||
const next = placeSignaturePlain(latestRef.current.plainBody, signatureIdentity);
|
||
setPlainBody(next);
|
||
baselineRef.current = snapshotOf({
|
||
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, body: next, attachments: initialAttachments,
|
||
});
|
||
// After the new text has rendered.
|
||
setTimeout(focusReplyBody, 0);
|
||
} else {
|
||
const sigHtml = buildEmbeddedSignatureHtml(signatureIdentity, { separator: signatureSeparatorEnabled });
|
||
void (async () => {
|
||
let live = latestRef.current.bodyHtml;
|
||
try {
|
||
live = (await editorRef.current?.getHtml()) ?? live;
|
||
} catch { /* keep the last change message */ }
|
||
if (containsEmbeddedSignature(live)) return;
|
||
const next = placeSignatureHtml(live, sigHtml);
|
||
setBodyHtml(next);
|
||
editorRef.current?.setHtml(next);
|
||
// The untouched baseline is the editor's own read-back: the page
|
||
// normalises the markup, so the string built here never equalled
|
||
// what it reports, and an untouched reply counted as edited (it
|
||
// autosaved a draft and asked "Save or discard?" on close).
|
||
let settled = placeSignatureHtml(initialBodyHtml, sigHtml);
|
||
try {
|
||
settled = (await editorRef.current?.getHtml()) ?? settled;
|
||
} catch { /* keep the built string */ }
|
||
if (latestRef.current.bodyHtml === next) setBodyHtml(settled);
|
||
baselineRef.current = snapshotOf({
|
||
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
|
||
body: settled, attachments: initialAttachments,
|
||
});
|
||
// The baseline is a ref: re-render so "edited" is worked out again.
|
||
bumpBaseline();
|
||
focusReplyBody();
|
||
})();
|
||
}
|
||
return;
|
||
}
|
||
// Identity switch: swap the signature block, leaving the text alone.
|
||
const prev = prevSignatureIdentityRef.current;
|
||
if (prev?.id === signatureIdentity?.id) return;
|
||
prevSignatureIdentityRef.current = signatureIdentity;
|
||
if (plainTextMode) {
|
||
setPlainBody((body) => {
|
||
let stripped = body;
|
||
if (prev && plainTextBodyHasSignature(body, prev)) {
|
||
stripped = plainTextBodyWithoutSignature(body, prev);
|
||
} else if (prev) {
|
||
const old = getPlainTextSignature(prev);
|
||
const marker = `${wantsSignatureSeparator(signatureSeparatorEnabled, prev) ? '\n\n-- \n' : '\n\n'}${old}`;
|
||
if (old && body.includes(marker)) stripped = body.replace(marker, '');
|
||
}
|
||
return signatureIdentity ? placeSignaturePlain(stripped, signatureIdentity) : stripped;
|
||
});
|
||
return;
|
||
}
|
||
const sigHtml = signatureIdentity
|
||
? buildEmbeddedSignatureHtml(signatureIdentity, { separator: signatureSeparatorEnabled })
|
||
: '';
|
||
void (async () => {
|
||
let live = latestRef.current.bodyHtml;
|
||
try {
|
||
live = (await editorRef.current?.getHtml()) ?? live;
|
||
} catch { /* keep the last change message */ }
|
||
const next = containsEmbeddedSignature(live)
|
||
? spliceSignature(live, sigHtml)
|
||
: placeSignatureHtml(live, sigHtml);
|
||
if (next !== live) {
|
||
setBodyHtml(next);
|
||
editorRef.current?.setHtml(next);
|
||
}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [primaryIdentity?.id, signatureIdentity?.id]);
|
||
|
||
// ── Outgoing message assembly ─────────────────────────────────────────
|
||
|
||
const senderAddress = React.useCallback((identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } => {
|
||
const name = sanitizeDisplayName(identity.name);
|
||
let from: EmailAddress;
|
||
if (fromOverride?.email.trim()) {
|
||
const overrideName = sanitizeDisplayName(fromOverride.name);
|
||
from = overrideName ? { name: overrideName, email: fromOverride.email.trim() } : { email: fromOverride.email.trim() };
|
||
} else {
|
||
const email = subAddressTag ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) : identity.email;
|
||
from = name ? { name, email } : { email };
|
||
}
|
||
// A From that isn't the identity's own address still goes out through
|
||
// the identity's envelope sender.
|
||
const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined;
|
||
return { from, envelopeMailFrom };
|
||
}, [fromOverride, subAddressTag, subAddressDelimiter]);
|
||
|
||
const buildOutgoing = React.useCallback((identity: Identity, liveHtml: string, opts: { forDraft: boolean }): OutgoingEmail => {
|
||
const { from, envelopeMailFrom } = senderAddress(identity);
|
||
if (!messageIdRef.current) messageIdRef.current = generateMessageId(identity.email);
|
||
|
||
let htmlBody: string | undefined;
|
||
let textBody: string;
|
||
let usedCids: string[] = [];
|
||
if (plainTextMode) {
|
||
textBody = plainBody;
|
||
} else {
|
||
const rewritten = rewriteInlineImages(liveHtml);
|
||
usedCids = rewritten.usedCids;
|
||
// Belt-and-suspenders sanitization: the editor uses execCommand which
|
||
// can preserve pasted <script>/<style>/etc. Strip them before sending.
|
||
const safeHtml = stripDangerousTags(opts.forDraft ? rewritten.html : stripEditorMarkers(rewritten.html));
|
||
htmlBody = `<div>${safeHtml}</div>`;
|
||
// The text part marks the quote with "> ", numbers ordered lists and
|
||
// keeps the "-- " signature delimiter, as plain-text mail does.
|
||
textBody = htmlToPlainText(safeHtml, {
|
||
mailMarkup: true,
|
||
imageAlt: (alt) => t('email_composer.image_alt.plain_text', '[Image: {alt}]', { alt }),
|
||
});
|
||
}
|
||
|
||
const inlineFromBody = usedCids
|
||
.map((cid) => inlineRegistryRef.current.get(cid))
|
||
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
|
||
.map<OutgoingAttachment>((e) => ({
|
||
blobId: liveBlobId(draftPartBlobsRef.current, e.blobId!),
|
||
type: e.type,
|
||
name: e.name,
|
||
size: e.size,
|
||
disposition: 'inline',
|
||
cid: e.cid,
|
||
}));
|
||
|
||
const fileAttachments = attachments
|
||
.filter((a) => !a.inline && a.blobId && !a.error)
|
||
.map<OutgoingAttachment>((a) => ({
|
||
blobId: liveBlobId(draftPartBlobsRef.current, a.blobId!),
|
||
type: a.type,
|
||
name: a.name,
|
||
size: a.size,
|
||
disposition: 'attachment',
|
||
}));
|
||
const outgoingAttachments = [...inlineFromBody, ...fileAttachments];
|
||
|
||
// RFC 5322 §3.6.4: only replies continue the thread; a forward starts
|
||
// a new one. The original's Message-ID (never its JMAP id) seeds
|
||
// In-Reply-To, and References accumulates the chain (#234). A draft
|
||
// keeps the headers it was saved with.
|
||
const threading = replyTo && !draft && mode !== 'forward'
|
||
? computeReplyThreadingHeaders({ messageId: replyTo.messageId, references: replyTo.references })
|
||
: draft && (draft.inReplyTo?.length || draft.references?.length)
|
||
? { inReplyTo: draft.inReplyTo ?? [], references: draft.references ?? draft.inReplyTo ?? [] }
|
||
: null;
|
||
|
||
const identityBcc = opts.forDraft ? [] : (identity.bcc ?? []).filter((r) => !!r.email);
|
||
const bccAll = [...expandRecipients(finalBcc).map(toAddress), ...identityBcc];
|
||
|
||
return {
|
||
from: [from],
|
||
to: expandRecipients(finalTo).map(toAddress),
|
||
cc: finalCc.length ? expandRecipients(finalCc).map(toAddress) : undefined,
|
||
bcc: bccAll.length ? bccAll : undefined,
|
||
// The identity's Reply-To rides along on every message sent with it.
|
||
replyTo: identity.replyTo?.length ? identity.replyTo : undefined,
|
||
subject,
|
||
htmlBody,
|
||
textBody,
|
||
attachments: outgoingAttachments.length ? outgoingAttachments : undefined,
|
||
inReplyTo: threading?.inReplyTo,
|
||
references: threading?.references,
|
||
messageId: messageIdRef.current,
|
||
requestReadReceipt,
|
||
envelopeMailFrom,
|
||
};
|
||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]);
|
||
|
||
// Save one draft version (create, then destroy the previous one - #849).
|
||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||
const identity = primaryIdentity;
|
||
if (!identity || !draftsMailbox || sendingRef.current) return draftIdRef.current;
|
||
// Another login is active: its Drafts and ids are not this draft's.
|
||
if (loginMoved()) return draftIdRef.current;
|
||
let html = latestRef.current.bodyHtml;
|
||
if (opts.live && !plainTextMode) {
|
||
try {
|
||
html = (await editorRef.current?.getHtml()) ?? html;
|
||
} catch { /* the last change message is the best we have */ }
|
||
if (html !== latestRef.current.bodyHtml) setBodyHtml(html);
|
||
// Reading the editor gave a switch time to land.
|
||
if (loginMoved()) return draftIdRef.current;
|
||
}
|
||
const snapshot = snapshotOf({
|
||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||
body: plainTextMode ? plainBody : html, attachments,
|
||
});
|
||
if (snapshot === lastSavedRef.current) return draftIdRef.current;
|
||
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
|
||
setDraftStatus('saving');
|
||
try {
|
||
const outgoing = buildOutgoing(identity, html, { forDraft: true });
|
||
// A draft From a group identity is saved in the group's Drafts, like
|
||
// its Sent copy (#1090); one without a Drafts folder there in the own.
|
||
const target = await sendTargetFor(identity, outgoing.from[0]?.email ?? identity.email, mailboxes);
|
||
if (loginMoved()) {
|
||
setDraftStatus('idle');
|
||
return draftIdRef.current;
|
||
}
|
||
const saveIn = target.accountId && target.draftsMailboxId
|
||
? { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId }
|
||
: { accountId: undefined, draftsMailboxId: draftsMailbox.id };
|
||
const saved = await saveDraftVersion(
|
||
outgoing,
|
||
saveIn.draftsMailboxId,
|
||
draftIdRef.current ?? undefined,
|
||
saveIn.accountId,
|
||
draftIdRef.current ? draftAccount() : undefined,
|
||
);
|
||
const id = saved.id;
|
||
draftIdRef.current = id;
|
||
draftAccountRef.current = saveIn.accountId;
|
||
// The previous version and its part blobs are gone: point every
|
||
// attachment at the parts of the version just created.
|
||
draftPartBlobsRef.current = rebaseDraftPartBlobs(
|
||
draftPartBlobsRef.current, outgoing.attachments ?? [], saved.attachments,
|
||
);
|
||
lastSavedRef.current = snapshot;
|
||
lastSaveAtRef.current = Date.now();
|
||
setDraftStatus('saved');
|
||
return id;
|
||
} catch (err) {
|
||
setDraftStatus('failed');
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
// Serialize saves: a save that starts while another is in flight waits
|
||
// for it, so two versions can never race (#303).
|
||
const saveDraft = (opts: { live: boolean }): Promise<string | null> => {
|
||
const previous = inflightSaveRef.current;
|
||
const promise = (async () => {
|
||
if (previous) {
|
||
try { await previous; } catch { /* reported by its own caller */ }
|
||
}
|
||
return saveDraftOnce(opts);
|
||
})();
|
||
inflightSaveRef.current = promise;
|
||
void promise.finally(() => {
|
||
if (inflightSaveRef.current === promise) inflightSaveRef.current = null;
|
||
});
|
||
return promise;
|
||
};
|
||
const saveDraftRef = React.useRef(saveDraft);
|
||
saveDraftRef.current = saveDraft;
|
||
|
||
// Debounced autosave: two seconds after the last change, rate-limited to
|
||
// one save per `autoSaveDraftInterval`.
|
||
React.useEffect(() => {
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
}
|
||
if (!isDirty || !needsSave || !draftsMailbox || !primaryIdentity) return;
|
||
const sinceLast = Date.now() - lastSaveAtRef.current;
|
||
const wait = Math.max(2000, autoSaveDraftInterval - sinceLast);
|
||
saveTimerRef.current = setTimeout(() => {
|
||
saveTimerRef.current = null;
|
||
if (sendingRef.current || !latestRef.current.needsSave) return;
|
||
// Live: the editor posts its content throttled, so read the DOM itself.
|
||
saveDraftRef.current({ live: true }).catch((err) => {
|
||
console.warn('[compose] autosave failed', err);
|
||
});
|
||
}, wait);
|
||
return () => {
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [currentSnapshot, draftsMailbox?.id, primaryIdentity?.id]);
|
||
|
||
// ── Close guard ──────────────────────────────────────────────────────
|
||
|
||
// Why a draft can't be saved right now: the identities didn't load (e.g.
|
||
// offline when the composer opened) or the account has no Drafts folder.
|
||
const draftUnsavableReason = !primaryIdentity
|
||
? t('email_composer.save_failed_no_identity', 'Your sender identities could not be loaded, so this draft cannot be saved. Check your connection, or copy your text before closing.')
|
||
: !draftsMailbox
|
||
? t('email_composer.save_failed_no_drafts', 'There is no Drafts folder to save this draft to.')
|
||
: null;
|
||
|
||
const saveAndClose = async (proceed: () => void) => {
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
}
|
||
// Closing after a save that couldn't run would drop the text as if it
|
||
// had been saved: stay open and say why.
|
||
if (draftUnsavableReason) {
|
||
Alert.alert(t('email_composer.save_failed', 'Failed to save'), draftUnsavableReason);
|
||
return;
|
||
}
|
||
// Another login is active: saving writes nothing (saveDraftOnce), so
|
||
// closing now would drop the text as if it had been saved.
|
||
if (loginMoved()) {
|
||
Alert.alert(
|
||
t('email_composer.login_switched_title', 'Account switched'),
|
||
t(
|
||
'email_composer.login_switched_save_body',
|
||
'You started this message in {account}. Switch back to that account to save it.',
|
||
{ account: ownerAccountLabel() },
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
setSavingDraft(true);
|
||
try {
|
||
await saveDraft({ live: true });
|
||
allowLeaveRef.current = true;
|
||
proceed();
|
||
} catch (err) {
|
||
// The server refused the draft: keep the text on screen rather than
|
||
// dropping it with nothing but an error (#702).
|
||
Alert.alert(
|
||
t('email_composer.save_failed', 'Failed to save'),
|
||
err instanceof Error ? err.message : String(err),
|
||
);
|
||
} finally {
|
||
setSavingDraft(false);
|
||
}
|
||
};
|
||
|
||
const discardAndClose = (proceed: () => void) => {
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
}
|
||
for (const a of attachments) a.abort?.abort();
|
||
// The draft goes with the discard: one this session autosaved, and a
|
||
// re-opened one too (webmail handleDiscardAndClose). Keeping the latest
|
||
// autosaved version of a re-opened draft kept the very edits the user
|
||
// discarded.
|
||
const autosaved = draftIdRef.current;
|
||
// In another login that id names another message; the draft stays.
|
||
if (autosaved && !loginMoved()) {
|
||
void (async () => {
|
||
try { await inflightSaveRef.current; } catch { /* ignore */ }
|
||
const id = draftIdRef.current ?? autosaved;
|
||
destroyEmails([id], draftAccountRef.current).catch((err) => console.warn('[compose] discard failed', err));
|
||
})();
|
||
}
|
||
allowLeaveRef.current = true;
|
||
proceed();
|
||
};
|
||
|
||
const showCloseDialog = (proceed: () => void) => {
|
||
if (draftUnsavableReason) {
|
||
Alert.alert(
|
||
t('email_composer.discard_draft_title', 'Discard draft?'),
|
||
draftUnsavableReason,
|
||
[
|
||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
||
{ text: t('email_composer.discard', 'Discard'), style: 'destructive', onPress: () => discardAndClose(proceed) },
|
||
],
|
||
);
|
||
return;
|
||
}
|
||
Alert.alert(
|
||
t('email_composer.close_draft_title', 'Save or discard draft?'),
|
||
t('email_composer.close_draft_message', 'You have unsaved changes. Would you like to save this as a draft or discard it?'),
|
||
[
|
||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
||
{ text: t('email_composer.discard', 'Discard'), style: 'destructive', onPress: () => discardAndClose(proceed) },
|
||
{ text: t('email_composer.save_draft', 'Save Draft'), onPress: () => { void saveAndClose(proceed); } },
|
||
],
|
||
);
|
||
};
|
||
|
||
// Every change is in the saved draft: closing loses nothing, so it asks
|
||
// nothing (webmail handleClose savedAlready). Read from the editor itself,
|
||
// whose change messages can lag the last keystrokes.
|
||
const savedAlready = async (): Promise<boolean> => {
|
||
if (!draftIdRef.current || !lastSavedRef.current || draftStatus === 'failed') return false;
|
||
let html = latestRef.current.bodyHtml;
|
||
if (!plainTextMode) {
|
||
try {
|
||
html = (await editorRef.current?.getHtml()) ?? html;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
const snapshot = snapshotOf({
|
||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||
body: plainTextMode ? latestRef.current.plainBody : html, attachments,
|
||
});
|
||
return snapshot === lastSavedRef.current;
|
||
};
|
||
|
||
// The OS back gesture / hardware back goes through the same guard as the
|
||
// header X. `usePreventRemove` (not a bare `beforeRemove` listener) also
|
||
// blocks the iOS modal swipe-down, which is dismissed natively otherwise.
|
||
// Re-dispatching the intercepted action doesn't come back here.
|
||
usePreventRemove(isDirty || needsSave, ({ data }) => {
|
||
if (allowLeaveRef.current) {
|
||
navigation.dispatch(data.action);
|
||
return;
|
||
}
|
||
void (async () => {
|
||
if (await savedAlready()) {
|
||
allowLeaveRef.current = true;
|
||
navigation.dispatch(data.action);
|
||
return;
|
||
}
|
||
showCloseDialog(() => navigation.dispatch(data.action));
|
||
})();
|
||
});
|
||
|
||
// Discard deletes the draft, also one without unsaved changes such as a
|
||
// re-opened draft or a scheduled message back for editing, which the X
|
||
// closes and keeps (webmail handleDiscardClick).
|
||
const onDiscard = () => {
|
||
if (!isDirty && !needsSave && !draftIdRef.current) {
|
||
allowLeaveRef.current = true;
|
||
navigation.goBack();
|
||
return;
|
||
}
|
||
Alert.alert(
|
||
t('email_composer.discard_draft_title', 'Discard draft?'),
|
||
t('email_composer.discard_draft_message', 'The draft will be deleted.'),
|
||
[
|
||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
||
{ text: t('email_composer.discard', 'Discard'), style: 'destructive', onPress: () => discardAndClose(() => navigation.goBack()) },
|
||
],
|
||
);
|
||
};
|
||
|
||
const onClose = () => {
|
||
navigation.goBack();
|
||
};
|
||
|
||
// ── Attachments ──────────────────────────────────────────────────────
|
||
const updateAttachment = (localId: string, patch: Partial<AttachmentEntry>) => {
|
||
setAttachments((prev) => prev.map((a) => (a.localId === localId ? { ...a, ...patch } : a)));
|
||
if (patch.blobId !== undefined || patch.error !== undefined) {
|
||
const cid = inlineRegistryRef.current;
|
||
const entry = Array.from(cid.values()).find((e) => e.localId === localId);
|
||
if (entry) cid.set(entry.cid!, { ...entry, ...patch });
|
||
}
|
||
};
|
||
|
||
const removeAttachment = (localId: string) => {
|
||
const removed = attachments.find((a) => a.localId === localId);
|
||
removed?.abort?.abort();
|
||
setAttachments((prev) => prev.filter((a) => a.localId !== localId));
|
||
if (removed?.inline && removed.cid) {
|
||
inlineRegistryRef.current.delete(removed.cid);
|
||
// Strip the editor's <img data-cid="…"> for this cid from the live DOM
|
||
// (not the last change message, which can lag typing).
|
||
const escCid = removed.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp(`<img\\b[^>]*\\sdata-cid=("${escCid}"|'${escCid}')[^>]*>`, 'gi');
|
||
void (async () => {
|
||
let live = latestRef.current.bodyHtml;
|
||
try {
|
||
live = (await editorRef.current?.getHtml()) ?? live;
|
||
} catch { /* fall back */ }
|
||
const stripped = live.replace(re, '');
|
||
if (stripped !== live) {
|
||
setBodyHtml(stripped);
|
||
editorRef.current?.setHtml(stripped);
|
||
}
|
||
})();
|
||
}
|
||
};
|
||
|
||
// Server limits: refuse files the upload endpoint would reject and keep
|
||
// the per-message attachment total under the mail capability's ceiling.
|
||
const checkAttachmentSize = (size: number, inline: boolean): boolean => {
|
||
const maxUpload = jmapClient.getMaxSizeUpload();
|
||
if (maxUpload && size > maxUpload) {
|
||
Alert.alert(
|
||
t('email_composer.attach', 'Attach'),
|
||
t('email_composer.attachment_too_large', 'This file is larger than the server allows ({size} > {max}).', {
|
||
size: formatBytes(size), max: formatBytes(maxUpload),
|
||
}),
|
||
);
|
||
return false;
|
||
}
|
||
const maxTotal = jmapClient.getMaxSizeAttachmentsPerEmail();
|
||
if (!inline && maxTotal) {
|
||
const total = attachments.filter((a) => !a.inline && !a.error).reduce((n, a) => n + a.size, 0) + size;
|
||
if (total > maxTotal) {
|
||
Alert.alert(
|
||
t('email_composer.attach', 'Attach'),
|
||
t('email_composer.attachments_too_large_total', 'The attachments would exceed the {max} this server allows per message.', {
|
||
max: formatBytes(maxTotal),
|
||
}),
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const addUploadEntry = (asset: {
|
||
name: string;
|
||
type: string;
|
||
size: number;
|
||
uri: string;
|
||
inline: boolean;
|
||
cid?: string;
|
||
}): AttachmentEntry => {
|
||
const localId = genLocalId();
|
||
const entry: AttachmentEntry = {
|
||
localId,
|
||
name: asset.name,
|
||
type: asset.type,
|
||
size: asset.size,
|
||
uri: asset.uri,
|
||
inline: asset.inline,
|
||
cid: asset.cid,
|
||
uploading: true,
|
||
abort: new AbortController(),
|
||
};
|
||
setAttachments((prev) => [...prev, entry]);
|
||
if (asset.inline && asset.cid) inlineRegistryRef.current.set(asset.cid, entry);
|
||
return entry;
|
||
};
|
||
|
||
// Attachments go into the blob store of the login the message belongs to;
|
||
// once another login is active they would land in that one's.
|
||
const refuseAttachAfterSwitch = (): boolean => {
|
||
if (!loginMoved()) return false;
|
||
Alert.alert(
|
||
t('email_composer.login_switched_title', 'Account switched'),
|
||
t(
|
||
'email_composer.login_switched_attach_body',
|
||
'You started this message in {account}. Switch back to that account to attach files.',
|
||
{ account: ownerAccountLabel() },
|
||
),
|
||
);
|
||
return true;
|
||
};
|
||
|
||
const startUpload = async (entry: AttachmentEntry) => {
|
||
try {
|
||
const { blobId, size, type } = await uploadFileForLogin(ownerLoginRef.current, entry.uri, entry.type, {
|
||
signal: entry.abort?.signal,
|
||
onProgress: (sent, total) => {
|
||
if (total > 0) updateAttachment(entry.localId, { progress: Math.min(1, sent / total) });
|
||
},
|
||
});
|
||
updateAttachment(entry.localId, {
|
||
blobId,
|
||
type: type || entry.type,
|
||
size: size || entry.size,
|
||
uploading: false,
|
||
progress: undefined,
|
||
abort: undefined,
|
||
});
|
||
} catch (e) {
|
||
if (e instanceof Error && e.name === 'AbortError') return; // removed by the user
|
||
const message = e instanceof ComposerLoginMovedError
|
||
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
|
||
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
|
||
updateAttachment(entry.localId, { uploading: false, progress: undefined, error: message });
|
||
Alert.alert(
|
||
t('email_composer.upload_failed', 'Failed to upload {filename}', { filename: entry.name }),
|
||
message,
|
||
);
|
||
}
|
||
};
|
||
|
||
const addFileAsset = (asset: { name: string; type: string; size: number; uri: string }) => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
if (!checkAttachmentSize(asset.size, false)) return;
|
||
void startUpload(addUploadEntry({ ...asset, inline: false }));
|
||
};
|
||
|
||
// Files shared into the app (Android SEND / iOS share sheet) start uploading
|
||
// right away.
|
||
React.useEffect(() => {
|
||
if (!prefillAttachments?.length) return;
|
||
for (const a of prefillAttachments) {
|
||
addFileAsset({ name: a.name, type: a.type || 'application/octet-stream', size: a.size ?? 0, uri: a.uri });
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const pickPhotoAttachments = async () => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
// The system photo picker needs no media permission (and Play rejects
|
||
// READ_MEDIA_IMAGES for picking a photo now and then).
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ['images', 'videos'],
|
||
allowsMultipleSelection: true,
|
||
quality: 0.9,
|
||
exif: false,
|
||
});
|
||
if (result.canceled) return;
|
||
for (const asset of result.assets) {
|
||
const fallbackName = asset.fileName
|
||
|| `attachment-${Date.now()}.${(asset.mimeType ?? 'application/octet-stream').split('/')[1] ?? 'bin'}`;
|
||
addFileAsset({
|
||
name: fallbackName,
|
||
type: asset.mimeType ?? 'application/octet-stream',
|
||
size: asset.fileSize ?? 0,
|
||
uri: asset.uri,
|
||
});
|
||
}
|
||
};
|
||
|
||
const takePhotoAttachment = async () => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||
if (!perm.granted) {
|
||
Alert.alert(
|
||
t('email_composer.attach', 'Attach'),
|
||
t('email_composer.permission_camera', 'Camera permission is required to take a photo.'),
|
||
);
|
||
return;
|
||
}
|
||
const result = await ImagePicker.launchCameraAsync({ quality: 0.9, exif: false });
|
||
if (result.canceled || !result.assets[0]) return;
|
||
const asset = result.assets[0];
|
||
const mime = asset.mimeType ?? 'image/jpeg';
|
||
addFileAsset({
|
||
name: asset.fileName || `photo-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`,
|
||
type: mime,
|
||
size: asset.fileSize ?? 0,
|
||
uri: asset.uri,
|
||
});
|
||
};
|
||
|
||
const pickFileAttachments = async () => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
let result: DocumentPicker.DocumentPickerResult;
|
||
try {
|
||
result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true });
|
||
} catch (e) {
|
||
const message = e instanceof Error ? e.message : String(e);
|
||
if (/cancel/i.test(message)) return;
|
||
Alert.alert(t('email_composer.attach', 'Attach'), message);
|
||
return;
|
||
}
|
||
if (result.canceled) return;
|
||
for (const picked of result.assets) {
|
||
addFileAsset({
|
||
name: picked.name || `attachment-${Date.now()}`,
|
||
type: picked.mimeType || 'application/octet-stream',
|
||
size: picked.size ?? 0,
|
||
uri: picked.uri,
|
||
});
|
||
}
|
||
};
|
||
|
||
const [attachMenuOpen, setAttachMenuOpen] = React.useState(false);
|
||
// Files in the user's Files on the server, when it keeps them (#1179).
|
||
const [filesPickerOpen, setFilesPickerOpen] = React.useState(false);
|
||
const hasServerFiles = supportsFiles();
|
||
const attachOptions: SheetOption[] = [
|
||
{ label: t('email_composer.attach_photos', 'Photos & Videos'), onPress: () => { void pickPhotoAttachments(); } },
|
||
{ label: t('email_composer.attach_camera', 'Camera'), onPress: () => { void takePhotoAttachment(); } },
|
||
{
|
||
// Next to "Attach from Files" a plain "Files" would read as the same.
|
||
label: hasServerFiles
|
||
? t('email_composer.attach_from_device', 'Upload from this device')
|
||
: t('email_composer.attach_files', 'Files'),
|
||
onPress: () => { void pickFileAttachments(); },
|
||
},
|
||
...(hasServerFiles ? [{
|
||
label: t('email_composer.attach_from_files', 'Attach from Files'),
|
||
onPress: () => { if (!refuseAttachAfterSwitch()) setFilesPickerOpen(true); },
|
||
}] : []),
|
||
];
|
||
|
||
// A file from Files is attached by its blob: nothing is downloaded or
|
||
// uploaded, except a copy when Files lives in another account.
|
||
const attachFileNodes = (nodes: FileNode[]) => {
|
||
setFilesPickerOpen(false);
|
||
for (const node of nodes) {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
if (!checkAttachmentSize(node.size ?? 0, false)) continue;
|
||
const entry: AttachmentEntry = {
|
||
localId: genLocalId(),
|
||
name: node.name,
|
||
type: node.type || 'application/octet-stream',
|
||
size: node.size ?? 0,
|
||
uri: '',
|
||
inline: false,
|
||
uploading: true,
|
||
};
|
||
setAttachments((prev) => [...prev, entry]);
|
||
void (async () => {
|
||
try {
|
||
assertComposerLogin(ownerLoginRef.current);
|
||
const part = await attachmentFromFileNode(node);
|
||
updateAttachment(entry.localId, { blobId: part.blobId, type: part.type, size: part.size || entry.size, uploading: false });
|
||
} catch (e) {
|
||
const message = e instanceof ComposerLoginMovedError
|
||
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
|
||
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
|
||
updateAttachment(entry.localId, { uploading: false, error: message });
|
||
}
|
||
})();
|
||
}
|
||
};
|
||
|
||
const insertInlineImages = async (assets: Array<{ uri: string; mimeType?: string | null; fileName?: string | null; fileSize?: number | null }>) => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
for (const asset of assets) {
|
||
const mime = asset.mimeType ?? 'image/jpeg';
|
||
const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`;
|
||
if (!checkAttachmentSize(asset.fileSize ?? 0, true)) continue;
|
||
const cid = genCid();
|
||
// Read the picked image as a data URL so it shows up immediately in the
|
||
// editor. At send time the data URL is rewritten to `cid:<id>` and the
|
||
// matching inline part is added via the registry.
|
||
const dataUrl = await readUriAsDataUrl(asset.uri, mime);
|
||
if (!dataUrl) {
|
||
Alert.alert(t('email_composer.attach', 'Attach'), t('email_composer.image_load_failed', 'Could not load image'));
|
||
continue;
|
||
}
|
||
// Not the file name as alt text: the user is asked what it shows.
|
||
editorRef.current?.insertImage(dataUrl, cid, null);
|
||
setAltQueue((q) => [...q, { cid, src: dataUrl }]);
|
||
const entry = addUploadEntry({
|
||
name: fallbackName,
|
||
type: mime,
|
||
size: asset.fileSize ?? 0,
|
||
uri: asset.uri,
|
||
inline: true,
|
||
cid,
|
||
});
|
||
void startUpload(entry);
|
||
}
|
||
};
|
||
|
||
const insertInlineImage = async () => {
|
||
if (refuseAttachAfterSwitch()) return;
|
||
// The system photo picker needs no media permission (and Play rejects
|
||
// READ_MEDIA_IMAGES for picking a photo now and then).
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ['images'],
|
||
allowsMultipleSelection: true,
|
||
quality: 0.9,
|
||
exif: false,
|
||
});
|
||
if (result.canceled || result.assets.length === 0) return;
|
||
await insertInlineImages(result.assets);
|
||
};
|
||
|
||
// Open a chip in the file preview: local files from their URI, server
|
||
// blobs (forwarded, from a draft) after a download.
|
||
const previewAttachment = async (entry: AttachmentEntry) => {
|
||
try {
|
||
if (entry.uri) {
|
||
await filePreview.open({ from: 'local', uri: entry.uri, name: entry.name, type: entry.type, size: entry.size });
|
||
} else if (entry.blobId) {
|
||
await filePreview.open({ from: 'blob', blobId: entry.blobId, name: entry.name, type: entry.type, size: entry.size });
|
||
}
|
||
} catch (err) {
|
||
Alert.alert(t('email_composer.attach', 'Attach'), err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
|
||
// ── Format ───────────────────────────────────────────────────────────
|
||
|
||
// Switch this one message between rich and plain text (#1022). The body is
|
||
// converted in place and the embedded signature survives in the target
|
||
// format (lib/compose-format). Formatting is dropped going to plain text
|
||
// and doesn't come back on a second switch; images the user inserted stay
|
||
// on as ordinary attachments rather than silently not being sent.
|
||
const togglePlainTextMode = async () => {
|
||
const separator = signatureSeparatorEnabled;
|
||
if (plainTextMode) {
|
||
const next = plainComposeBodyToHtml(plainBody, signatureIdentity, { separator });
|
||
setBodyHtml(next);
|
||
setEditorSeedHtml(next);
|
||
setPlainTextMode(false);
|
||
return;
|
||
}
|
||
let html = latestRef.current.bodyHtml;
|
||
try {
|
||
html = (await editorRef.current?.getHtml()) ?? html;
|
||
} catch { /* the last change message is the best we have */ }
|
||
setPlainBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator }));
|
||
setAttachments((prev) => (prev.some((a) => a.inline) ? prev.map((a) => (a.inline ? { ...a, inline: false } : a)) : prev));
|
||
setPlainTextMode(true);
|
||
};
|
||
|
||
// ── Image description (Modal) ────────────────────────────────────────
|
||
// Asked right after an image goes in, one image at a time (webmail
|
||
// image-alt-dialog, 53cb49d3c). "Decorative" writes alt="" so screen
|
||
// readers skip it; Skip leaves it undescribed.
|
||
const [altQueue, setAltQueue] = React.useState<Array<{ cid: string; src: string }>>([]);
|
||
const altTarget = altQueue[0] ?? null;
|
||
const [altText, setAltText] = React.useState('');
|
||
const [altDecorative, setAltDecorative] = React.useState(false);
|
||
React.useEffect(() => {
|
||
setAltText('');
|
||
setAltDecorative(false);
|
||
}, [altTarget?.cid]);
|
||
const closeAltPrompt = () => setAltQueue((q) => q.slice(1));
|
||
const saveAltPrompt = () => {
|
||
if (!altTarget) return;
|
||
const alt = altDecorative ? '' : altText.trim();
|
||
if (!altDecorative && !alt) return;
|
||
editorRef.current?.setImageAlt(altTarget.cid, alt);
|
||
closeAltPrompt();
|
||
};
|
||
|
||
// ── Link prompt (Modal) ──────────────────────────────────────────────
|
||
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
||
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
||
|
||
const openLinkPrompt = () => {
|
||
setLinkPromptValue('https://');
|
||
setLinkPromptVisible(true);
|
||
};
|
||
|
||
const submitLinkPrompt = () => {
|
||
const trimmed = linkPromptValue.trim();
|
||
setLinkPromptVisible(false);
|
||
if (!trimmed || trimmed === 'https://' || trimmed === 'http://') return;
|
||
const url = URL_RE.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||
editorRef.current?.insertLink(url);
|
||
};
|
||
|
||
const onLinkPress = () => {
|
||
if (selState.link) {
|
||
editorRef.current?.unsetLink();
|
||
return;
|
||
}
|
||
openLinkPrompt();
|
||
};
|
||
|
||
// ── Colour / table ───────────────────────────────────────────────────
|
||
const [colorTarget, setColorTarget] = React.useState<'text' | 'background' | null>(null);
|
||
const [tableMenuOpen, setTableMenuOpen] = React.useState(false);
|
||
const insertTable = (rows: number, cols: number) => {
|
||
const cell = '<td style="border:1px solid #cccccc;padding:6px;min-width:40px"> </td>';
|
||
const row = `<tr>${cell.repeat(cols)}</tr>`;
|
||
editorRef.current?.insertHtml(
|
||
`<table style="border-collapse:collapse;width:100%"><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`,
|
||
);
|
||
};
|
||
const tableOptions: SheetOption[] = [
|
||
{ label: '2 × 2', onPress: () => insertTable(2, 2) },
|
||
{ label: '3 × 3', onPress: () => insertTable(3, 3) },
|
||
{ label: '4 × 3', onPress: () => insertTable(4, 3) },
|
||
];
|
||
|
||
// ── Templates ────────────────────────────────────────────────────────
|
||
const [templateSheetOpen, setTemplateSheetOpen] = React.useState(false);
|
||
const [placeholderPrompt, setPlaceholderPrompt] = React.useState<{
|
||
template: EmailTemplate;
|
||
auto: Record<string, string>;
|
||
values: Record<string, string>;
|
||
} | null>(null);
|
||
|
||
const insertPlainText = (text: string) => {
|
||
const sel = plainSelectionRef.current;
|
||
setPlainBody((prev) => {
|
||
const start = sel ? Math.min(sel.start, prev.length) : prev.length;
|
||
const end = sel ? Math.min(sel.end, prev.length) : prev.length;
|
||
return prev.slice(0, start) + text + prev.slice(end);
|
||
});
|
||
};
|
||
|
||
const applyTemplate = (template: EmailTemplate, values: Record<string, string>) => {
|
||
const hasValues = Object.keys(values).length > 0;
|
||
const filledSubject = hasValues ? substitutePlaceholders(template.subject, values) : template.subject;
|
||
const filledBody = hasValues ? substitutePlaceholders(template.body, values) : template.body;
|
||
// An empty template subject must not wipe one the user typed (#540).
|
||
if (filledSubject && !subject.trim()) setSubject(filledSubject);
|
||
if (plainTextMode) {
|
||
insertPlainText(template.isHTML ? htmlToPlainText(filledBody) : filledBody);
|
||
} else {
|
||
// Inserted at the caret so it lands after any text already typed and
|
||
// leaves the signature / quote alone (#539/#540).
|
||
editorRef.current?.insertHtml(templateBodyToHtml({ body: filledBody, isHTML: template.isHTML }));
|
||
}
|
||
const addChips = (field: Field, list?: string[]) => {
|
||
if (!list?.length) return;
|
||
const chips = list.map(parseRecipient).map(fromParsed).filter(chipIsValid);
|
||
if (!chips.length) return;
|
||
setterFor(field)((prev) => {
|
||
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
|
||
return [...prev, ...chips.filter((r) => !existing.has(r.email.toLowerCase()))];
|
||
});
|
||
if (field === 'cc') setCcVisible(true);
|
||
if (field === 'bcc') { setCcVisible(true); setBccVisible(true); }
|
||
};
|
||
addChips('to', template.defaultRecipients?.to);
|
||
addChips('cc', template.defaultRecipients?.cc);
|
||
addChips('bcc', template.defaultRecipients?.bcc);
|
||
if (template.identityId && identities.some((i) => i.id === template.identityId)) {
|
||
setSelectedIdentityId(template.identityId);
|
||
}
|
||
};
|
||
|
||
const onPickTemplate = (template: EmailTemplate) => {
|
||
const placeholders = getPlaceholdersFromTemplate(template);
|
||
const auto = getAutoFilledPlaceholders({
|
||
senderName: primaryIdentity?.name || undefined,
|
||
recipientName: finalTo[0]?.name || undefined,
|
||
locale,
|
||
});
|
||
const missing = placeholders.filter((p) => auto[p] === undefined);
|
||
if (missing.length === 0) {
|
||
applyTemplate(template, placeholders.length ? auto : {});
|
||
return;
|
||
}
|
||
setPlaceholderPrompt({
|
||
template,
|
||
auto,
|
||
values: Object.fromEntries(missing.map((m) => [m, ''])),
|
||
});
|
||
};
|
||
|
||
// ── From options (sub-address tag / override) ────────────────────────
|
||
const [fromOptionsOpen, setFromOptionsOpen] = React.useState(false);
|
||
const [tagDraft, setTagDraft] = React.useState('');
|
||
const [overrideEnabled, setOverrideEnabled] = React.useState(false);
|
||
const [overrideName, setOverrideName] = React.useState('');
|
||
const [overrideEmail, setOverrideEmail] = React.useState('');
|
||
const openFromOptions = () => {
|
||
setTagDraft(subAddressTag);
|
||
setOverrideEnabled(!!fromOverride);
|
||
setOverrideName(fromOverride?.name ?? primaryIdentity?.name ?? '');
|
||
setOverrideEmail(fromOverride?.email ?? primaryIdentity?.email ?? '');
|
||
setFromOptionsOpen(true);
|
||
};
|
||
const tagError = tagDraft ? getTagValidationError(tagDraft) : null;
|
||
const tagSuggestions = React.useMemo(() => {
|
||
const domains = finalTo.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
|
||
.map(extractDomain)
|
||
.filter((d): d is string => !!d);
|
||
const out: string[] = [];
|
||
for (const d of domains) for (const s of suggestTagsForDomain(d)) if (!out.includes(s)) out.push(s);
|
||
return out.slice(0, 5);
|
||
}, [finalTo]);
|
||
const applyFromOptions = () => {
|
||
if (overrideEnabled) {
|
||
const email = overrideEmail.trim();
|
||
if (!isValidEmail(email)) {
|
||
Alert.alert(t('email_composer.from_override.email_label', 'From email address'), t('identities.form.email_invalid', 'Please enter a valid email address'));
|
||
return;
|
||
}
|
||
setFromOverride({ name: overrideName.trim(), email });
|
||
setSubAddressTag('');
|
||
} else {
|
||
setFromOverride(null);
|
||
if (tagDraft && tagError) return;
|
||
setSubAddressTag(tagDraft.trim());
|
||
}
|
||
setFromOptionsOpen(false);
|
||
};
|
||
|
||
// ── Send ─────────────────────────────────────────────────────────────
|
||
|
||
// Catch the classic "I forgot the attachment" footgun. Only file attachments
|
||
// count - inline images don't satisfy the user's intent of attaching a file.
|
||
// Only the text the user wrote is scanned, not the quoted original (#570).
|
||
// Returns true if it's safe to proceed, false if the user cancelled.
|
||
const passesAttachmentReminder = async (body: string): Promise<boolean> => {
|
||
if (!attachmentReminderEnabled) return true;
|
||
const hasFileAttachment = attachments.some(
|
||
(a) => !a.inline && a.blobId && !a.error,
|
||
);
|
||
if (hasFileAttachment) return true;
|
||
const authored = extractUserAuthoredText(body, {
|
||
plainTextMode,
|
||
forwardedSeparator: quoteLabels.forwardedSeparator,
|
||
});
|
||
const matchedKeyword = findAttachmentKeyword(`${subject}\n${authored}`, attachmentReminderKeywords);
|
||
if (!matchedKeyword) return true;
|
||
return new Promise<boolean>((resolve) => {
|
||
Alert.alert(
|
||
t('email_composer.attachment_reminder_title', 'Forgot an attachment?'),
|
||
t(
|
||
'email_composer.attachment_reminder_body',
|
||
'Your message mentions "{keyword}" but no file is attached. Send anyway?',
|
||
{ keyword: matchedKeyword },
|
||
),
|
||
[
|
||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
|
||
{ text: t('email_composer.send', 'Send'), onPress: () => resolve(true) },
|
||
],
|
||
);
|
||
});
|
||
};
|
||
|
||
// Confirm an empty subject instead of silently blocking Send (#684).
|
||
// Asked once: after "Send anyway" and a trip back from a later prompt, the
|
||
// next Send doesn't ask again (webmail emptySubjectConfirmedRef).
|
||
const emptySubjectConfirmedRef = React.useRef(false);
|
||
const passesEmptySubjectCheck = (): Promise<boolean> => {
|
||
if (subject.trim() || !emptySubjectWarningEnabled || emptySubjectConfirmedRef.current) return Promise.resolve(true);
|
||
return new Promise<boolean>((resolve) => {
|
||
Alert.alert(
|
||
t('email_composer.empty_subject.title', 'Send without a subject?'),
|
||
t('email_composer.empty_subject.message', 'This message has no subject. Send it anyway?'),
|
||
[
|
||
{ text: t('email_composer.empty_subject.back', 'Back to editing'), style: 'cancel', onPress: () => resolve(false) },
|
||
{
|
||
text: t('email_composer.empty_subject.dont_ask_again', "Don't ask again"),
|
||
onPress: () => { updateSetting('emptySubjectWarningEnabled', false); resolve(true); },
|
||
},
|
||
{
|
||
text: t('email_composer.empty_subject.send_anyway', 'Send anyway'),
|
||
onPress: () => { emptySubjectConfirmedRef.current = true; resolve(true); },
|
||
},
|
||
],
|
||
);
|
||
});
|
||
};
|
||
|
||
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
|
||
// [TIME] from AI drafts, and markers like TODO (webmail
|
||
// lib/placeholder-check). The signature is left out, or a placeholder in
|
||
// it would stop every message. Asked once per placeholder: after "Send
|
||
// anyway" and a trip back to add an attachment, the same ones pass.
|
||
const confirmedPlaceholdersRef = React.useRef(new Set<string>());
|
||
const passesPlaceholderCheck = (body: string): Promise<boolean> => {
|
||
if (!placeholderWarningEnabled) return Promise.resolve(true);
|
||
const found = unfilledPlaceholdersInMessage({
|
||
subject,
|
||
body,
|
||
plainTextMode,
|
||
signature: signatureIdentity,
|
||
forwardedSeparator: quoteLabels.forwardedSeparator,
|
||
words: placeholderWordsFor(locale),
|
||
});
|
||
if (!found.some((p) => !confirmedPlaceholdersRef.current.has(p))) return Promise.resolve(true);
|
||
const SHOWN = 5;
|
||
const more = found.length > SHOWN
|
||
? ` ${t('email_composer.placeholder_warning.more', 'and {count} more', { count: found.length - SHOWN })}`
|
||
: '';
|
||
const message = t(
|
||
'email_composer.placeholder_warning.message',
|
||
'Your message has placeholder text that recipients will see. Do you want to edit it before sending?',
|
||
);
|
||
const label = t('email_composer.placeholder_warning.found', 'Placeholders found');
|
||
return new Promise<boolean>((resolve) => {
|
||
Alert.alert(
|
||
t('email_composer.placeholder_warning.title', 'Check placeholders?'),
|
||
`${message}\n\n${label}: ${found.slice(0, SHOWN).join(', ')}${more}`,
|
||
[
|
||
{ text: t('email_composer.placeholder_warning.back', 'Back to editing'), style: 'cancel', onPress: () => resolve(false) },
|
||
{
|
||
text: t('email_composer.placeholder_warning.dont_ask_again', "Don't ask again"),
|
||
onPress: () => { updateSetting('placeholderWarningEnabled', false); resolve(true); },
|
||
},
|
||
{
|
||
text: t('email_composer.placeholder_warning.send_anyway', 'Send anyway'),
|
||
onPress: () => {
|
||
for (const p of found) confirmedPlaceholdersRef.current.add(p);
|
||
resolve(true);
|
||
},
|
||
},
|
||
],
|
||
);
|
||
});
|
||
};
|
||
|
||
// "Send this message?" when "Confirm before sending" is on (webmail
|
||
// 4d91d9b29). Asked after every other check, so a warning answered first
|
||
// doesn't ask it twice.
|
||
const passesSendConfirmation = (): Promise<boolean> => {
|
||
if (!useSettingsStore.getState().sendConfirmation) return Promise.resolve(true);
|
||
const count = [finalTo, finalCc, finalBcc].reduce((n, list) => n + expandRecipients(list).length, 0);
|
||
return new Promise<boolean>((resolve) => {
|
||
Alert.alert(
|
||
t('email_composer.send_confirm.title', 'Send this message?'),
|
||
t('email_composer.send_confirm.message', 'It goes to {count, plural, one {# recipient} other {# recipients}}.', { count }),
|
||
[
|
||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
|
||
{ text: t('email_composer.send', 'Send'), onPress: () => resolve(true) },
|
||
],
|
||
{ cancelable: true, onDismiss: () => resolve(false) },
|
||
);
|
||
});
|
||
};
|
||
|
||
// Translate an absolute "send at" time into the HOLDFOR seconds the server
|
||
// expects, clamping to what it actually supports. Returns null (and alerts)
|
||
// when scheduling isn't possible so the caller can abort.
|
||
const resolveHoldForScheduledAt = (date: Date): number | null => {
|
||
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 null;
|
||
}
|
||
if (!jmapClient.hasDelayedSend()) {
|
||
Alert.alert(
|
||
t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'),
|
||
t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'),
|
||
);
|
||
return null;
|
||
}
|
||
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 null;
|
||
}
|
||
return seconds;
|
||
};
|
||
|
||
// The Send button: applies the global undo-send delay when the server
|
||
// supports it (capped at its hold limit), otherwise sends immediately.
|
||
const onSend = () => {
|
||
const holdFor = jmapClient.undoSendHold(sendDelaySeconds);
|
||
void performSend(holdFor);
|
||
};
|
||
|
||
const onScheduleConfirm = (date: Date) => {
|
||
const holdFor = resolveHoldForScheduledAt(date);
|
||
if (holdFor == null) return;
|
||
setScheduleSheetOpen(false);
|
||
void performSend(holdFor, date);
|
||
};
|
||
|
||
const formatWhen = (date: Date) => formatQuoteDate(date.toISOString(), timeFormat, locale);
|
||
|
||
const schedulePresets = React.useMemo(() => {
|
||
const now = new Date();
|
||
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
|
||
// Only offer times within the server's hold limit.
|
||
const maxMs = jmapClient.getMaxDelayedSend() * 1000;
|
||
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) },
|
||
// The webmail's: this morning and afternoon while still ahead (#1390),
|
||
// tomorrow at 8:00 and 13:00, next Monday at 8:00.
|
||
...webmailSchedulePresets(now).map((preset) => ({ label: schedulePresetLabel(preset.key, t), date: preset.date })),
|
||
].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [t, scheduleSheetOpen]);
|
||
|
||
const startCustomPicker = () => {
|
||
// An hour ahead, or the latest time the server can hold it if sooner.
|
||
const latest = jmapClient.latestHoldDate()?.getTime() ?? Infinity;
|
||
customDraftRef.current = new Date(Math.min(Date.now() + 3600 * 1000, latest));
|
||
setScheduleSheetOpen(false);
|
||
setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date');
|
||
};
|
||
|
||
const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => {
|
||
if (event.type === 'dismissed' || !selected) {
|
||
setCustomStage(null);
|
||
return;
|
||
}
|
||
if (Platform.OS === 'ios') {
|
||
// Single spinner — keep it open, just remember the latest value.
|
||
customDraftRef.current = selected;
|
||
return;
|
||
}
|
||
// Android: combine the date step with the existing time, then ask for time.
|
||
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);
|
||
setCustomStage(null);
|
||
onScheduleConfirm(d);
|
||
}
|
||
};
|
||
|
||
// Synchronous re-entry guard: `sending` state only flips after a few
|
||
// awaits below, so two quick taps could both pass `canSend` and submit
|
||
// twice. The same flag pauses the autosave while a send is in flight.
|
||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||
if (sendingRef.current) return;
|
||
sendingRef.current = true;
|
||
try {
|
||
await performSendInner(holdForSeconds, scheduledAt);
|
||
} finally {
|
||
sendingRef.current = false;
|
||
}
|
||
};
|
||
|
||
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||
if (!primaryIdentity || !sentMailbox) return;
|
||
const refuseSendAfterSwitch = (): boolean => {
|
||
if (!loginMoved()) return false;
|
||
Alert.alert(
|
||
t('email_composer.login_switched_title', 'Account switched'),
|
||
t(
|
||
'email_composer.login_switched_body',
|
||
'You started this message in {account}. Switch back to that account to send it.',
|
||
{ account: ownerAccountLabel() },
|
||
),
|
||
);
|
||
return true;
|
||
};
|
||
if (refuseSendAfterSwitch()) return;
|
||
|
||
// Read the body straight from the editor DOM at send time. The `change`
|
||
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
||
// here once shipped replies with the typed text silently missing when the
|
||
// page script had died (issue #9). If the editor doesn't answer we abort
|
||
// loudly rather than send possibly-stale content.
|
||
let liveBodyHtml = bodyHtml;
|
||
if (!plainTextMode) {
|
||
try {
|
||
liveBodyHtml = (await editorRef.current?.getHtml()) ?? bodyHtml;
|
||
} catch {
|
||
Alert.alert(
|
||
t('email_composer.send_failed', 'Send failed'),
|
||
t(
|
||
'email_composer.editor_unavailable',
|
||
'Could not read the message content. Copy your text, then close and reopen the composer.',
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
if (liveBodyHtml !== bodyHtml) setBodyHtml(liveBodyHtml);
|
||
}
|
||
|
||
if (!(await passesEmptySubjectCheck())) return;
|
||
if (!(await passesPlaceholderCheck(plainTextMode ? plainBody : liveBodyHtml))) return;
|
||
if (!(await passesAttachmentReminder(plainTextMode ? plainBody : liveBodyHtml))) return;
|
||
if (!(await passesSendConfirmation())) return;
|
||
|
||
// Autosave must not race the send: cancel a scheduled save and let an
|
||
// in-flight one finish so its version can be destroyed after the send.
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
}
|
||
if (inflightSaveRef.current) {
|
||
try { await inflightSaveRef.current; } catch { /* stale version stays; cleaned up below */ }
|
||
}
|
||
// The confirmations above wait for the user, long enough for a
|
||
// notification tap to switch logins.
|
||
if (refuseSendAfterSwitch()) return;
|
||
|
||
setSending(true);
|
||
try {
|
||
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
||
// The account the submission is held in, for Undo (read before the
|
||
// request: a switch may land while it is out).
|
||
// A group or shared identity sends from its own account, and its Sent
|
||
// copy goes there (#1090).
|
||
const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
|
||
if (refuseSendAfterSwitch()) return;
|
||
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined;
|
||
const result = await sendEmail(
|
||
outgoing,
|
||
target.identityId,
|
||
target.sentMailboxId ?? sentMailbox.id,
|
||
holdForSeconds,
|
||
{
|
||
draftsMailboxId: target.accountId ? target.draftsMailboxId : draftsMailbox?.id,
|
||
draftId: draftIdRef.current ?? undefined,
|
||
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
|
||
accountId: target.accountId,
|
||
},
|
||
);
|
||
draftIdRef.current = null;
|
||
lastSavedRef.current = null;
|
||
// Each send leaves a submission record; sweep the ones that can go
|
||
// (throttled per account).
|
||
void pruneEmailSubmissions();
|
||
if (result.filingWarning) {
|
||
console.warn('[compose] post-send filing warning:', result.filingWarning);
|
||
}
|
||
// The server refused some recipients outright while it took the others.
|
||
const refused = result.refusedRecipients ?? [];
|
||
if (refused.length > 0) {
|
||
const { title, message } = partlyRefusedWarning(refused, t);
|
||
toast.warning(title, { message, duration: 10_000 });
|
||
}
|
||
// A switch during the send: the original's id and the trusted senders
|
||
// book below are the other login's now.
|
||
const sameLogin = !loginMoved();
|
||
// Flag the original so the list shows the reply/forward arrow; best
|
||
// effort - the message already left.
|
||
if (replyTo?.originalEmailId && sameLogin) {
|
||
void patchKeywordsForEmails(
|
||
[replyTo.originalEmailId],
|
||
{ [mode === 'forward' ? '$forwarded' : '$answered']: true },
|
||
replyTo.jmapAccountId,
|
||
).catch(() => undefined);
|
||
}
|
||
// People you reply to are people you trust: allow their remote content
|
||
// from now on (webmail 1.5.x).
|
||
if (isReplyLike && mode !== 'forward') {
|
||
const settings = useSettingsStore.getState();
|
||
const contacts = useContactsStore.getState();
|
||
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
|
||
settings.addTrustedSender(r.email);
|
||
if (sameLogin && isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
|
||
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
|
||
}
|
||
}
|
||
}
|
||
if (scheduledAt && result.scheduled) {
|
||
// Confirm an explicit "send later" so the user knows it didn't go out now.
|
||
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
|
||
Alert.alert(
|
||
t('email_composer.scheduled_title', 'Scheduled'),
|
||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.', {
|
||
// The server can land a second early: 08:00 read 07:59.
|
||
time: formatScheduledSendAt(when.toISOString(), timeFormat, locale),
|
||
}),
|
||
);
|
||
} else if (result.scheduled) {
|
||
// Undo-send window: the undo bar offers Undo / Send now.
|
||
useSendUndoStore.getState().recordHeldSend(result, holdForSeconds, {
|
||
// The identity the message went out through, as the submission
|
||
// account names it: "Send now" submits again with it.
|
||
identityId: target.identityId,
|
||
accountId: submissionAccountId,
|
||
login: ownerLoginRef.current,
|
||
from: outgoing.from,
|
||
to: [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])],
|
||
});
|
||
} else if (refused.length === 0) {
|
||
// Held sends get the undo bar instead (webmail b03a0c1d), and refused
|
||
// recipients the warning above.
|
||
toast.success(t('notifications.email_sent', 'Email sent successfully'));
|
||
}
|
||
allowLeaveRef.current = true;
|
||
navigation.goBack();
|
||
} catch (e) {
|
||
if (e instanceof RequestTimeoutError) {
|
||
// The request may have reached the server; a blind retry would send
|
||
// the message twice (#702).
|
||
Alert.alert(
|
||
t('email_composer.send_timeout_title', 'No answer from the server'),
|
||
t(
|
||
'email_composer.send_timeout_body',
|
||
'The message may already have gone out. Check your Sent folder before sending it again.',
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
if (e instanceof SendUnconfirmedError) {
|
||
// The message may have gone out: the composer and the draft stay,
|
||
// and the user checks Sent rather than sending it twice (#1019).
|
||
Alert.alert(
|
||
t('email_composer.send_unconfirmed_title', 'Send not confirmed'),
|
||
t(
|
||
'email_composer.send_unconfirmed_body',
|
||
'The server did not confirm the send. Check your Sent folder before sending again. Your draft has been kept.',
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
if (e instanceof ScheduleTooLateError) {
|
||
// The server refused the hold; the pickers now only offer times
|
||
// within the limit it named.
|
||
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;
|
||
}
|
||
Alert.alert(t('email_composer.send_failed', 'Send failed'), composerSendFailureMessage(e, t));
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
const titleKey =
|
||
mode === 'forward' ? 'email_composer.forward'
|
||
: mode === 'replyAll' ? 'email_composer.reply_all'
|
||
: replyTo ? 'email_composer.reply'
|
||
: 'email_composer.new_message';
|
||
|
||
const fromDisplay = React.useMemo(() => {
|
||
if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...');
|
||
const { from } = senderAddress(primaryIdentity);
|
||
// An IDN domain arrives in its xn-- form; show it as written (#1100).
|
||
const email = toUnicodeEmail(from.email);
|
||
return from.name ? `${from.name} <${email}>` : email;
|
||
}, [primaryIdentity, identityError, senderAddress, t]);
|
||
|
||
const draftStatusText =
|
||
savingDraft || draftStatus === 'saving' ? t('email_composer.saving', 'Saving...')
|
||
: draftStatus === 'saved' ? t('email_composer.draft_saved', 'Draft saved')
|
||
: draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save')
|
||
: '';
|
||
|
||
const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
|
||
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
|
||
const input = inputFor(field);
|
||
const setInput = inputSetterFor(field);
|
||
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
|
||
return (
|
||
<View style={[styles.fieldRow, { zIndex }]}>
|
||
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
|
||
<View style={styles.recipientField}>
|
||
{list.map((r, i) => (
|
||
<RecipientChip
|
||
key={`${r.email}-${i}`}
|
||
recipient={r}
|
||
invalid={!chipIsValid(r)}
|
||
onRemove={() => removeChip(field, i)}
|
||
onLongPress={() => setChipMenu({ field, index: i })}
|
||
/>
|
||
))}
|
||
<TextInput
|
||
// No full-screen keyboard in landscape: it would hide the suggestions
|
||
// and the text around the field.
|
||
disableFullscreenUI
|
||
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
|
||
placeholder={placeholder}
|
||
placeholderTextColor={c.textMuted}
|
||
value={input}
|
||
onChangeText={(text) => {
|
||
// A separator ends the entry: commit immediately so a pasted
|
||
// list turns into chips as it lands.
|
||
setInput(text);
|
||
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
|
||
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
|
||
if (valid.length) {
|
||
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
|
||
setInput(invalid.join(' '));
|
||
}
|
||
}
|
||
}}
|
||
onFocus={() => setActiveField(field)}
|
||
onBlur={() => {
|
||
setTimeout(() => {
|
||
if (!isPickingSuggestion.current) {
|
||
addTyped(field);
|
||
}
|
||
setActiveField((f) => (f === field ? null : f));
|
||
isPickingSuggestion.current = false;
|
||
}, 200);
|
||
}}
|
||
// Return turns typed text into a chip; on an empty field it
|
||
// moves on to the subject.
|
||
returnKeyType="next"
|
||
onSubmitEditing={() => {
|
||
if (inputFor(field).trim()) addTyped(field);
|
||
else subjectRef.current?.focus();
|
||
}}
|
||
blurOnSubmit={false}
|
||
keyboardType="email-address"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
// The contact suggestions below are the autocomplete here, not
|
||
// the system's autofill (webmail: autoComplete off).
|
||
autoComplete="off"
|
||
importantForAutofill="no"
|
||
textContentType="none"
|
||
/>
|
||
</View>
|
||
{activeField === field && suggestions.length > 0 && (
|
||
<SuggestionList
|
||
suggestions={suggestions}
|
||
onPick={pickSuggestion}
|
||
onPressIn={() => {
|
||
isPickingSuggestion.current = true;
|
||
}}
|
||
/>
|
||
)}
|
||
{field === 'to' && !ccVisible && (
|
||
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button">
|
||
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
|
||
</Pressable>
|
||
)}
|
||
{field === 'cc' && !bccVisible && (
|
||
<Pressable onPress={() => setBccVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button">
|
||
<Text style={styles.ccToggleText}>{t('email_composer.bcc', 'Bcc')}</Text>
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const composer = (
|
||
<SafeAreaView style={styles.container} edges={isSplit ? [] : ['top', 'left', 'right']}>
|
||
<View style={[styles.header, typingInShortWindow && styles.headerCompact]}>
|
||
<Pressable
|
||
onPress={onClose}
|
||
style={styles.headerBtn}
|
||
disabled={savingDraft}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.close', 'Close')}
|
||
>
|
||
{savingDraft ? <ActivityIndicator size="small" color={c.text} /> : <X size={22} color={c.text} />}
|
||
</Pressable>
|
||
<View style={styles.headerTitleWrap}>
|
||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
|
||
</Text>
|
||
{!!draftStatusText && !typingInShortWindow && (
|
||
<Text style={styles.headerSubtitle} numberOfLines={1}>{draftStatusText}</Text>
|
||
)}
|
||
</View>
|
||
<View style={styles.headerRight}>
|
||
<Pressable
|
||
onPress={onDiscard}
|
||
style={styles.headerBtn}
|
||
hitSlop={8}
|
||
disabled={savingDraft}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('email_composer.discard', 'Discard')}
|
||
>
|
||
<Trash2 size={20} color={c.text} />
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => setAttachMenuOpen(true)}
|
||
style={styles.headerBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('email_composer.attach', 'Attach')}
|
||
>
|
||
<Paperclip size={20} color={c.text} />
|
||
</Pressable>
|
||
{/* Only offer scheduling when the server can hold the message (webmail parity). */}
|
||
{jmapClient.hasDelayedSend() && (
|
||
<Pressable
|
||
onPress={() => setScheduleSheetOpen(true)}
|
||
style={styles.headerBtn}
|
||
hitSlop={8}
|
||
disabled={!canSend}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('email_composer.schedule_send', 'Schedule send')}
|
||
>
|
||
<Clock size={20} color={canSend ? c.text : c.textMuted} />
|
||
</Pressable>
|
||
)}
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
onPress={onSend}
|
||
disabled={!canSend}
|
||
icon={
|
||
sending ? (
|
||
<ActivityIndicator color={c.primaryForeground} size="small" />
|
||
) : (
|
||
<Send
|
||
size={14}
|
||
color={canSend ? c.primaryForeground : c.textMuted}
|
||
/>
|
||
)
|
||
}
|
||
style={!canSend ? styles.sendButtonDisabled : undefined}
|
||
>
|
||
{sending ? t('email_composer.sending', 'Sending...') : t('email_composer.send', 'Send')}
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
|
||
<ScrollView
|
||
ref={bodyScrollRef}
|
||
style={styles.flex}
|
||
keyboardShouldPersistTaps="always"
|
||
contentContainerStyle={styles.scrollContent}
|
||
onScroll={(e) => { bodyScrollY.current = e.nativeEvent.contentOffset.y; }}
|
||
scrollEventThrottle={16}
|
||
>
|
||
<View style={styles.fieldRow}>
|
||
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.from', 'From')}</Text>
|
||
<Pressable
|
||
onPress={openIdentityPicker}
|
||
onLongPress={openFromOptions}
|
||
disabled={identities.length <= 1 && !primaryIdentity}
|
||
style={styles.fieldContent}
|
||
>
|
||
<Text style={[styles.fromText, !!fromOverride && styles.fromTextOverride]} numberOfLines={1}>
|
||
{fromDisplay}
|
||
</Text>
|
||
{identities.length > 1 && <ChevronDown size={14} color={c.textMuted} />}
|
||
</Pressable>
|
||
{!!primaryIdentity && (
|
||
<Pressable
|
||
onPress={openFromOptions}
|
||
hitSlop={8}
|
||
style={styles.fromOptionsBtn}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('email_composer.from_options', 'Sender options')}
|
||
>
|
||
<Tag size={16} color={subAddressTag || fromOverride ? c.primary : c.textMuted} />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
|
||
{renderRecipientField('to', t('email_composer.to', 'To'), t('email_composer.to_placeholder', 'Recipient email addresses'), 6)}
|
||
{ccVisible && renderRecipientField('cc', t('email_composer.cc', 'Cc'), t('email_composer.cc_placeholder', 'Cc recipients'), 5)}
|
||
{bccVisible && renderRecipientField('bcc', t('email_composer.bcc', 'Bcc'), t('email_composer.bcc_placeholder', 'Bcc recipients'), 4)}
|
||
|
||
<View style={styles.fieldRow}>
|
||
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.subject', 'Subject')}</Text>
|
||
<TextInput
|
||
ref={subjectRef}
|
||
disableFullscreenUI
|
||
style={styles.subjectInput}
|
||
placeholder={t('email_composer.subject_placeholder', 'Subject')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={subject}
|
||
onChangeText={setSubject}
|
||
// Unfocused, a long subject shows from its start ("Re: …"), not
|
||
// scrolled to its end.
|
||
selection={subjectFocused ? undefined : SELECTION_START}
|
||
onFocus={() => setSubjectFocused(true)}
|
||
onBlur={() => setSubjectFocused(false)}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => {
|
||
if (plainTextMode) plainBodyRef.current?.focus();
|
||
else editorRef.current?.focus();
|
||
}}
|
||
/>
|
||
</View>
|
||
|
||
{attachments.length > 0 && (
|
||
<View style={styles.attachmentList}>
|
||
{attachments.map((a) => (
|
||
<AttachmentChip
|
||
key={a.localId}
|
||
attachment={a}
|
||
onRemove={() => removeAttachment(a.localId)}
|
||
onPress={() => { void previewAttachment(a); }}
|
||
/>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{plainTextMode ? (
|
||
<TextInput
|
||
ref={plainBodyRef}
|
||
disableFullscreenUI
|
||
style={styles.plainEditor}
|
||
multiline
|
||
value={plainBody}
|
||
onChangeText={setPlainBody}
|
||
onSelectionChange={(e) => { plainSelectionRef.current = e.nativeEvent.selection; }}
|
||
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||
placeholderTextColor={c.textMuted}
|
||
textAlignVertical="top"
|
||
autoCorrect
|
||
/>
|
||
) : (
|
||
<RichTextEditor
|
||
ref={editorRef}
|
||
initialHtml={editorSeedHtml}
|
||
blockRemoteImages={blockRemoteImages}
|
||
// A reply's quoted history starts folded behind "•••" and still
|
||
// goes out with the reply (webmail #1405). Only the editor built
|
||
// on open: one rebuilt by a switch from plain text shows it all.
|
||
collapseQuote={collapseReplyQuote && editorSeedHtml === initialBodyHtml}
|
||
quoteToggleLabels={quoteToggleLabels}
|
||
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||
onChange={setBodyHtml}
|
||
onSelectionChange={setSelState}
|
||
onViewportShift={onEditorViewportShift}
|
||
/>
|
||
)}
|
||
{/* The empty space under the body looks like part of it (on a
|
||
tablet, most of the panel): a tap there starts typing in it. */}
|
||
<Pressable
|
||
style={styles.bodyFiller}
|
||
accessible={false}
|
||
onPress={() => {
|
||
if (plainTextMode) plainBodyRef.current?.focus();
|
||
else editorRef.current?.focusEnd();
|
||
}}
|
||
/>
|
||
</ScrollView>
|
||
|
||
{/* A phone in landscape with the keyboard up has room for a few lines
|
||
of text only; the format bar comes back with the keyboard down. */}
|
||
{!typingInShortWindow && (
|
||
<ScrollView
|
||
horizontal
|
||
showsHorizontalScrollIndicator={false}
|
||
style={styles.formatBar}
|
||
contentContainerStyle={styles.formatActions}
|
||
keyboardShouldPersistTaps="always"
|
||
>
|
||
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
|
||
label={t('email_composer.use_template', 'Template')}
|
||
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
|
||
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
||
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
|
||
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
|
||
label={plainTextMode
|
||
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
|
||
: t('email_composer.format_plain_text', 'Switch to plain text')}
|
||
icon={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
|
||
|
||
{!plainTextMode && (
|
||
<>
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
|
||
label={t('email_composer.toolbar.bold', 'Bold')}
|
||
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
|
||
label={t('email_composer.toolbar.italic', 'Italic')}
|
||
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
|
||
label={t('email_composer.toolbar.underline', 'Underline')}
|
||
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
|
||
label={t('email_composer.toolbar.strikethrough', 'Strikethrough')}
|
||
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => setColorTarget('text')}
|
||
label={t('email_composer.toolbar.text_color', 'Text color')}
|
||
icon={<Palette size={18} color={c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => setColorTarget('background')}
|
||
label={t('email_composer.toolbar.background_color', 'Background color')}
|
||
icon={<Highlighter size={18} color={c.textSecondary} />} />
|
||
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
|
||
label={t('email_composer.toolbar.heading_1', 'Heading 1')}
|
||
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
|
||
label={t('email_composer.toolbar.heading_2', 'Heading 2')}
|
||
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
|
||
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
|
||
label={t('email_composer.toolbar.bullet_list', 'Bullet list')}
|
||
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
|
||
label={t('email_composer.toolbar.ordered_list', 'Ordered list')}
|
||
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
|
||
label={t('email_composer.toolbar.quote', 'Quote')}
|
||
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => setTableMenuOpen(true)}
|
||
label={t('email_composer.toolbar.table', 'Table')}
|
||
icon={<Table size={18} color={c.textSecondary} />} />
|
||
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
|
||
label={t('email_composer.toolbar.align_left', 'Align left')}
|
||
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
|
||
label={t('email_composer.toolbar.align_center', 'Align center')}
|
||
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
|
||
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
|
||
label={t('email_composer.toolbar.align_right', 'Align right')}
|
||
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
|
||
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton active={selState.link} onPress={onLinkPress}
|
||
label={selState.link ? t('email_composer.toolbar.remove_link', 'Remove link') : t('email_composer.toolbar.link', 'Link')}
|
||
icon={selState.link
|
||
? <Link2Off size={18} color={c.primary} />
|
||
: <Link2 size={18} color={c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => { void insertInlineImage(); }}
|
||
label={t('email_composer.toolbar.insert_image', 'Insert image')}
|
||
icon={<ImageIcon size={18} color={c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
|
||
label={t('email_composer.toolbar.clear_formatting', 'Clear formatting')}
|
||
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
|
||
|
||
<View style={styles.formatSep} />
|
||
|
||
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
|
||
label={t('email_composer.toolbar.undo', 'Undo')}
|
||
icon={<Undo2 size={18} color={c.textSecondary} />} />
|
||
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
||
label={t('email_composer.toolbar.redo', 'Redo')}
|
||
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
)}
|
||
</View>
|
||
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||
visible={linkPromptVisible}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setLinkPromptVisible(false)}
|
||
>
|
||
<View style={styles.modalBackdrop}>
|
||
<View style={styles.modalCard}>
|
||
<Text style={styles.modalTitle}>
|
||
{t('email_composer.add_link', 'Add link')}
|
||
</Text>
|
||
<Text style={styles.modalLabel}>
|
||
{t('email_composer.link_url_prompt', 'Enter the URL')}
|
||
</Text>
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={linkPromptValue}
|
||
onChangeText={setLinkPromptValue}
|
||
placeholder="https://example.com"
|
||
placeholderTextColor={c.textMuted}
|
||
keyboardType="url"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
autoFocus
|
||
onSubmitEditing={submitLinkPrompt}
|
||
/>
|
||
<View style={styles.modalActions}>
|
||
<Pressable
|
||
style={styles.modalCancel}
|
||
onPress={() => setLinkPromptVisible(false)}
|
||
hitSlop={4}
|
||
>
|
||
<Text style={styles.modalCancelText}>
|
||
{t('email_composer.cancel', 'Cancel')}
|
||
</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={styles.modalConfirm}
|
||
onPress={submitLinkPrompt}
|
||
hitSlop={4}
|
||
>
|
||
<Text style={styles.modalConfirmText}>
|
||
{t('confirm_dialog.confirm', 'Confirm')}
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||
visible={!!altTarget}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={closeAltPrompt}
|
||
>
|
||
<View style={styles.modalBackdrop}>
|
||
<View style={styles.modalCard}>
|
||
<Text style={styles.modalTitle}>{t('email_composer.image_alt.title', 'Describe this image')}</Text>
|
||
<Text style={styles.modalLabel}>
|
||
{t('email_composer.image_alt.hint', "Say what the image shows for people who can't see it.")}
|
||
</Text>
|
||
{!!altTarget && (
|
||
<Image source={{ uri: altTarget.src }} style={styles.altPreview} resizeMode="contain" accessible={false} />
|
||
)}
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={altDecorative ? '' : altText}
|
||
onChangeText={setAltText}
|
||
editable={!altDecorative}
|
||
placeholder={t('email_composer.image_alt.label', 'Image description')}
|
||
accessibilityLabel={t('email_composer.image_alt.label', 'Image description')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoFocus
|
||
onSubmitEditing={saveAltPrompt}
|
||
/>
|
||
<View style={styles.switchRow}>
|
||
<Text style={styles.switchLabel}>{t('email_composer.image_alt.decorative', 'Decorative image (no description)')}</Text>
|
||
<ToggleSwitch
|
||
value={altDecorative}
|
||
onValueChange={setAltDecorative}
|
||
accessibilityLabel={t('email_composer.image_alt.decorative', 'Decorative image (no description)')}
|
||
/>
|
||
</View>
|
||
<View style={styles.modalActions}>
|
||
<Pressable style={styles.modalCancel} onPress={closeAltPrompt} hitSlop={4}>
|
||
<Text style={styles.modalCancelText}>{t('email_composer.image_alt.skip', 'Skip')}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.modalConfirm, !altDecorative && !altText.trim() && styles.sendButtonDisabled]}
|
||
onPress={saveAltPrompt}
|
||
disabled={!altDecorative && !altText.trim()}
|
||
hitSlop={4}
|
||
>
|
||
<Text style={styles.modalConfirmText}>{t('common.save', 'Save')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
|
||
{/* Text / background colour palette */}
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||
<Text style={styles.modalTitle}>
|
||
{colorTarget === 'background'
|
||
? t('email_composer.toolbar.background_color', 'Background color')
|
||
: t('email_composer.toolbar.text_color', 'Text color')}
|
||
</Text>
|
||
<View style={styles.swatchRow}>
|
||
{TEXT_COLORS.map((color) => (
|
||
<Pressable
|
||
key={color}
|
||
accessibilityLabel={color}
|
||
onPress={() => {
|
||
setColorTarget(null);
|
||
editorRef.current?.exec(colorTarget === 'background' ? `hiliteColor:${color}` : `foreColor:${color}`);
|
||
}}
|
||
style={[styles.swatch, { backgroundColor: color }]}
|
||
/>
|
||
))}
|
||
</View>
|
||
{colorTarget === 'background' && (
|
||
<Pressable
|
||
style={styles.scheduleRow}
|
||
onPress={() => { setColorTarget(null); editorRef.current?.exec('hiliteColor:transparent'); }}
|
||
>
|
||
<RemoveFormatting size={16} color={c.textSecondary} />
|
||
<Text style={styles.scheduleRowLabel}>
|
||
{t('email_composer.toolbar.remove_background_color', 'Remove background color')}
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
<Pressable style={styles.scheduleCancel} onPress={() => setColorTarget(null)}>
|
||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Modal>
|
||
|
||
<OptionsSheet
|
||
visible={tableMenuOpen}
|
||
title={t('email_composer.toolbar.table', 'Table')}
|
||
options={tableOptions}
|
||
onClose={() => setTableMenuOpen(false)}
|
||
cancelLabel={t('email_composer.cancel', 'Cancel')}
|
||
/>
|
||
|
||
<OptionsSheet
|
||
visible={attachMenuOpen}
|
||
title={t('email_composer.attach', 'Attach')}
|
||
options={attachOptions}
|
||
onClose={() => setAttachMenuOpen(false)}
|
||
cancelLabel={t('email_composer.cancel', 'Cancel')}
|
||
/>
|
||
|
||
<OptionsSheet
|
||
visible={!!chipMenu}
|
||
title={chipMenuRecipient ? (chipMenuRecipient.group ? chipMenuRecipient.name : (chipMenuRecipient.name ? `${chipMenuRecipient.name} <${chipMenuRecipient.email}>` : chipMenuRecipient.email)) : undefined}
|
||
options={chipMenuOptions}
|
||
onClose={() => setChipMenu(null)}
|
||
cancelLabel={t('email_composer.cancel', 'Cancel')}
|
||
/>
|
||
|
||
{/* Schedule send sheet */}
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||
visible={scheduleSheetOpen}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setScheduleSheetOpen(false)}
|
||
>
|
||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||
<Text style={styles.modalTitle}>
|
||
{t('email_composer.schedule_send', 'Schedule send')}
|
||
</Text>
|
||
{schedulePresets.map((preset) => (
|
||
<Pressable
|
||
key={preset.label}
|
||
style={styles.scheduleRow}
|
||
onPress={() => onScheduleConfirm(preset.date)}
|
||
>
|
||
<Clock size={16} color={c.textSecondary} />
|
||
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
|
||
<Text style={styles.scheduleRowTime}>{formatWhen(preset.date)}</Text>
|
||
</Pressable>
|
||
))}
|
||
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
|
||
<Check size={16} color={c.textSecondary} />
|
||
<Text style={styles.scheduleRowLabel}>
|
||
{t('email_composer.schedule_custom', 'Pick date & time…')}
|
||
</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={styles.scheduleCancel}
|
||
onPress={() => setScheduleSheetOpen(false)}
|
||
>
|
||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Modal>
|
||
|
||
{customStage !== null && Platform.OS === 'ios' && (
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||
<DateTimePicker
|
||
value={customDraftRef.current}
|
||
mode="datetime"
|
||
display="spinner"
|
||
minimumDate={new Date()}
|
||
maximumDate={jmapClient.latestHoldDate()}
|
||
onChange={onCustomPickerChange}
|
||
/>
|
||
<View style={styles.modalActions}>
|
||
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
|
||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={styles.modalConfirm}
|
||
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
|
||
>
|
||
<Text style={styles.modalConfirmText}>
|
||
{t('email_composer.schedule_send', 'Schedule send')}
|
||
</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}
|
||
maximumDate={customStage === 'date' ? jmapClient.latestHoldDate() : undefined}
|
||
onChange={onCustomPickerChange}
|
||
/>
|
||
)}
|
||
|
||
{/* Template placeholders */}
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||
visible={!!placeholderPrompt}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setPlaceholderPrompt(null)}
|
||
>
|
||
<View style={styles.modalBackdrop}>
|
||
<View style={styles.modalCard}>
|
||
<Text style={styles.modalTitle}>{t('templates.fill_placeholders', 'Fill Placeholder Values')}</Text>
|
||
<ScrollView style={{ maxHeight: 320 }} keyboardShouldPersistTaps="handled">
|
||
{Object.keys(placeholderPrompt?.values ?? {}).map((name) => (
|
||
<View key={name} style={{ gap: 4, marginBottom: spacing.sm }}>
|
||
<Text style={styles.modalLabel}>
|
||
{t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
|
||
</Text>
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={placeholderPrompt?.values[name] ?? ''}
|
||
onChangeText={(v) => setPlaceholderPrompt((p) => (p ? { ...p, values: { ...p.values, [name]: v } } : p))}
|
||
placeholder={t('templates.enter_value', 'Enter a value...')}
|
||
placeholderTextColor={c.textMuted}
|
||
/>
|
||
</View>
|
||
))}
|
||
</ScrollView>
|
||
<View style={styles.modalActions}>
|
||
<Pressable
|
||
style={styles.modalCancel}
|
||
onPress={() => {
|
||
const p = placeholderPrompt;
|
||
setPlaceholderPrompt(null);
|
||
if (p) applyTemplate(p.template, {});
|
||
}}
|
||
>
|
||
<Text style={styles.modalCancelText}>{t('templates.insert_raw', 'Insert Raw')}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={styles.modalConfirm}
|
||
onPress={() => {
|
||
const p = placeholderPrompt;
|
||
setPlaceholderPrompt(null);
|
||
if (!p) return;
|
||
const values: Record<string, string> = { ...p.auto };
|
||
for (const [k, v] of Object.entries(p.values)) if (v.trim()) values[k] = v.trim();
|
||
applyTemplate(p.template, values);
|
||
}}
|
||
>
|
||
<Text style={styles.modalConfirmText}>{t('templates.insert_with_values', 'Insert with Values')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
|
||
{/* From options: sub-address tag / From override */}
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={fromOptionsOpen} transparent animationType="fade" onRequestClose={() => setFromOptionsOpen(false)}>
|
||
<View style={styles.modalBackdrop}>
|
||
<View style={styles.modalCard}>
|
||
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
|
||
{!overrideEnabled && (
|
||
<>
|
||
<Text style={styles.modalLabel}>{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}</Text>
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={tagDraft}
|
||
onChangeText={setTagDraft}
|
||
placeholder={t('identities.sub_address.tag_input_placeholder', 'Enter tag (e.g., shopping)')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
{!!tagError && (
|
||
<Text style={styles.scheduleWarning}>
|
||
{tagError === 'TOO_LONG'
|
||
? t('identities.sub_address.validation.too_long', 'Tag must be {max} characters or less', { max: MAX_TAG_LENGTH })
|
||
: t('identities.sub_address.validation.invalid_chars', 'Tag must contain only letters, numbers, and dashes')}
|
||
</Text>
|
||
)}
|
||
{tagSuggestions.length > 0 && (
|
||
<View style={styles.tagRow}>
|
||
{tagSuggestions.map((s) => (
|
||
<Pressable key={s} onPress={() => setTagDraft(s)} style={styles.tagChip}>
|
||
<Text style={styles.tagChipText}>{s}</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
)}
|
||
{!!primaryIdentity && (
|
||
<Text style={styles.modalLabel} numberOfLines={1}>
|
||
{t('identities.sub_address.preview_label', 'Preview:')} {toUnicodeEmail(tagDraft && !tagError
|
||
? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter)
|
||
: primaryIdentity.email)}
|
||
</Text>
|
||
)}
|
||
</>
|
||
)}
|
||
<View style={styles.switchRow}>
|
||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||
<ToggleSwitch
|
||
value={overrideEnabled}
|
||
onValueChange={setOverrideEnabled}
|
||
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
||
/>
|
||
</View>
|
||
{overrideEnabled && (
|
||
<>
|
||
<Text style={styles.modalLabel}>{t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')}</Text>
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={overrideName}
|
||
onChangeText={setOverrideName}
|
||
placeholder={t('email_composer.from_override.name_placeholder', 'Name')}
|
||
placeholderTextColor={c.textMuted}
|
||
/>
|
||
<TextInput
|
||
style={styles.modalInput}
|
||
value={overrideEmail}
|
||
onChangeText={setOverrideEmail}
|
||
placeholder={t('email_composer.from_override.email_placeholder', 'alias@example.com')}
|
||
placeholderTextColor={c.textMuted}
|
||
keyboardType="email-address"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
</>
|
||
)}
|
||
<View style={styles.modalActions}>
|
||
<Pressable style={styles.modalCancel} onPress={() => setFromOptionsOpen(false)}>
|
||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.modalConfirm} onPress={applyFromOptions}>
|
||
<Text style={styles.modalConfirmText}>{t('confirm_dialog.confirm', 'Confirm')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
|
||
<FilesPickerSheet
|
||
visible={filesPickerOpen}
|
||
mode="files"
|
||
onClose={() => setFilesPickerOpen(false)}
|
||
onPickFiles={attachFileNodes}
|
||
/>
|
||
|
||
<TemplateSheet
|
||
visible={templateSheetOpen}
|
||
onClose={() => setTemplateSheetOpen(false)}
|
||
onPick={onPickTemplate}
|
||
/>
|
||
|
||
<IdentitySheet
|
||
visible={identitySheetOpen}
|
||
onClose={() => setIdentitySheetOpen(false)}
|
||
identities={identities}
|
||
selectedIdentityId={selectedIdentityId}
|
||
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); setIdentityPickedByUser(true); }}
|
||
/>
|
||
|
||
<AttachmentPreviewModal {...filePreview.modalProps} />
|
||
</SafeAreaView>
|
||
);
|
||
if (!isSplit) return composer;
|
||
// Tablet: a centred panel over the dimmed screen it was opened from.
|
||
return (
|
||
<View style={[styles.panelBackdrop, { paddingTop: insets.top + PANEL_MARGIN, paddingBottom: insets.bottom + PANEL_MARGIN }]}>
|
||
<View style={styles.panel}>{composer}</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const SELECTION_START = { start: 0, end: 0 };
|
||
|
||
/** Gap between the tablet composer panel and the screen edges. */
|
||
const PANEL_MARGIN = 24;
|
||
|
||
function makeStyles(c: ThemePalette) {
|
||
return StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: c.background },
|
||
flex: { flex: 1 },
|
||
panelBackdrop: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||
alignItems: 'center',
|
||
paddingHorizontal: PANEL_MARGIN,
|
||
},
|
||
panel: {
|
||
flex: 1,
|
||
width: '100%',
|
||
maxWidth: 760,
|
||
borderRadius: radius.lg,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
overflow: 'hidden',
|
||
backgroundColor: c.background,
|
||
},
|
||
scrollContent: { flexGrow: 1 },
|
||
bodyFiller: { flexGrow: 1, minHeight: 48 },
|
||
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.border,
|
||
gap: spacing.sm,
|
||
},
|
||
headerCompact: { paddingVertical: 0 },
|
||
headerBtn: {
|
||
width: componentSizes.buttonLg,
|
||
height: componentSizes.buttonLg,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: radius.full,
|
||
},
|
||
headerTitleWrap: { flex: 1, minWidth: 0 },
|
||
headerTitle: { ...typography.h3, color: c.text },
|
||
headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: -2 },
|
||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||
sendButtonDisabled: { opacity: 0.5 },
|
||
|
||
fieldRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
paddingHorizontal: spacing.lg,
|
||
paddingVertical: spacing.sm,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.borderLight,
|
||
gap: spacing.md,
|
||
position: 'relative',
|
||
},
|
||
fieldLabel: {
|
||
...typography.body,
|
||
color: c.textMuted,
|
||
paddingTop: 10,
|
||
},
|
||
fieldContent: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: 10,
|
||
gap: spacing.sm,
|
||
},
|
||
fromText: { ...typography.body, color: c.text, flexShrink: 1 },
|
||
fromTextOverride: { fontStyle: 'italic' },
|
||
fromOptionsBtn: { paddingTop: 10, paddingHorizontal: 4 },
|
||
recipientField: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
gap: spacing.xs,
|
||
paddingVertical: 4,
|
||
},
|
||
chip: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.surfaceActive,
|
||
maxWidth: 220,
|
||
},
|
||
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
|
||
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||
chipTextInvalid: { color: c.error },
|
||
recipientInput: {
|
||
flexGrow: 1,
|
||
minWidth: 100,
|
||
...typography.body,
|
||
color: c.text,
|
||
paddingVertical: 6,
|
||
},
|
||
recipientInputInvalid: { color: c.error },
|
||
suggestionBox: {
|
||
position: 'absolute',
|
||
top: '100%',
|
||
left: spacing.lg + 56 + spacing.md,
|
||
right: spacing.lg,
|
||
borderWidth: 1,
|
||
borderColor: c.borderLight,
|
||
borderRadius: radius.sm,
|
||
backgroundColor: c.card,
|
||
zIndex: 20,
|
||
elevation: 5,
|
||
overflow: 'hidden',
|
||
},
|
||
suggestionRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: spacing.sm,
|
||
gap: spacing.sm,
|
||
},
|
||
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||
suggestionAvatar: {
|
||
width: 32, height: 32,
|
||
borderRadius: 16,
|
||
backgroundColor: c.primaryBg,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
|
||
suggestionText: { flex: 1 },
|
||
suggestionName: { ...typography.bodyMedium, color: c.text },
|
||
suggestionEmail: { ...typography.caption, color: c.textSecondary },
|
||
ccToggle: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 6,
|
||
},
|
||
ccToggleText: { ...typography.caption, color: c.primary },
|
||
subjectInput: {
|
||
flex: 1,
|
||
...typography.body,
|
||
color: c.text,
|
||
paddingVertical: 10,
|
||
},
|
||
plainEditor: {
|
||
...typography.body,
|
||
color: c.text,
|
||
minHeight: 220,
|
||
paddingHorizontal: spacing.lg,
|
||
paddingVertical: spacing.md,
|
||
lineHeight: 22,
|
||
},
|
||
|
||
attachmentList: {
|
||
paddingHorizontal: spacing.lg,
|
||
paddingVertical: spacing.sm,
|
||
gap: spacing.xs,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.borderLight,
|
||
},
|
||
attachmentChip: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.sm,
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: spacing.xs,
|
||
borderRadius: radius.sm,
|
||
backgroundColor: c.surfaceActive,
|
||
},
|
||
attachmentMeta: { flex: 1, minWidth: 0 },
|
||
attachmentName: { ...typography.bodyMedium, color: c.text },
|
||
attachmentSize: { ...typography.caption, color: c.textMuted },
|
||
attachmentRemove: { padding: 4 },
|
||
progressTrack: { height: 3, borderRadius: 2, backgroundColor: c.borderLight, marginTop: 4, overflow: 'hidden' },
|
||
progressFill: { height: 3, backgroundColor: c.primary },
|
||
|
||
formatBar: {
|
||
borderTopWidth: 1,
|
||
borderTopColor: c.border,
|
||
backgroundColor: c.surface,
|
||
flexGrow: 0,
|
||
},
|
||
formatActions: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.xs,
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.xs,
|
||
},
|
||
formatBtn: {
|
||
width: componentSizes.buttonMd,
|
||
height: componentSizes.buttonMd,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: radius.sm,
|
||
},
|
||
formatBtnActive: {
|
||
backgroundColor: c.primaryBg,
|
||
},
|
||
formatBtnDisabled: { opacity: 0.4 },
|
||
formatSep: {
|
||
width: 1,
|
||
height: 20,
|
||
backgroundColor: c.borderLight,
|
||
marginHorizontal: 2,
|
||
},
|
||
|
||
modalBackdrop: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
padding: spacing.lg,
|
||
},
|
||
modalCard: {
|
||
width: '100%',
|
||
maxWidth: 420,
|
||
backgroundColor: c.background,
|
||
borderRadius: radius.md,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
padding: spacing.lg,
|
||
gap: spacing.md,
|
||
},
|
||
modalTitle: { ...typography.h3, color: c.text },
|
||
modalLabel: { ...typography.caption, color: c.textSecondary },
|
||
modalInput: {
|
||
...typography.body,
|
||
color: c.text,
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
borderRadius: radius.sm,
|
||
borderWidth: 1,
|
||
borderColor: c.borderLight,
|
||
backgroundColor: c.surface,
|
||
},
|
||
modalActions: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-end',
|
||
gap: spacing.sm,
|
||
},
|
||
modalCancel: {
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
borderRadius: radius.sm,
|
||
},
|
||
modalCancelText: { ...typography.bodyMedium, color: c.textSecondary },
|
||
modalConfirm: {
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
borderRadius: radius.sm,
|
||
backgroundColor: c.primary,
|
||
},
|
||
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||
altPreview: { width: '100%', height: 96, borderRadius: radius.sm },
|
||
switchLabel: { ...typography.body, color: c.text },
|
||
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||
tagChip: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.surfaceActive,
|
||
},
|
||
tagChipText: { ...typography.caption, color: c.text },
|
||
swatchRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, paddingVertical: spacing.sm },
|
||
swatch: { width: 36, height: 36, borderRadius: 18, borderWidth: 1, borderColor: c.border },
|
||
scheduleCard: {
|
||
width: '100%',
|
||
maxWidth: 420,
|
||
backgroundColor: c.background,
|
||
borderRadius: radius.md,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
padding: spacing.lg,
|
||
gap: spacing.xs,
|
||
},
|
||
scheduleWarning: {
|
||
...typography.caption,
|
||
color: c.error,
|
||
marginBottom: spacing.xs,
|
||
},
|
||
scheduleRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.sm,
|
||
paddingVertical: spacing.sm + 2,
|
||
minHeight: 44,
|
||
},
|
||
scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||
scheduleRowTime: { ...typography.caption, color: c.textMuted },
|
||
scheduleCancel: {
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.sm,
|
||
marginTop: spacing.xs,
|
||
},
|
||
});
|
||
}
|