mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
- Implemented a new RichTextEditor component for composing and editing rich text emails. - Enhanced EmailThreadScreen to support downloading attachments with loading indicators. - Added functionality to expand/collapse attachment list in EmailThreadScreen. - Created utility functions for building initial HTML for email composition and converting HTML to plain text. - Added tests for HTML composition and conversion utilities.
1149 lines
39 KiB
TypeScript
1149 lines
39 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
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,
|
|
} from 'lucide-react-native';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
|
import { useColors } from '../theme/colors';
|
|
import { Button } from '../components';
|
|
import RichTextEditor, {
|
|
type RichTextEditorHandle,
|
|
type RichTextSelectionState,
|
|
} from '../components/RichTextEditor';
|
|
import { useEmailStore } from '../stores/email-store';
|
|
import { useContactsStore } from '../stores/contacts-store';
|
|
import { useLocaleStore } from '../stores/locale-store';
|
|
import { isGroup } from '../lib/contact-utils';
|
|
import {
|
|
getContactDisplayName,
|
|
getContactInitials,
|
|
matchesContactSearch,
|
|
} from '../lib/contact-utils';
|
|
import { getIdentities } from '../api/identity';
|
|
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
|
import { uploadBlob } from '../api/blob';
|
|
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
|
import { stripDangerousTags } from '../lib/email-html';
|
|
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
|
import type { RootStackParamList } from '../navigation/types';
|
|
|
|
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
|
|
|
interface Recipient {
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
type AttachmentEntry = {
|
|
localId: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
uri: string;
|
|
inline: boolean;
|
|
cid?: string;
|
|
blobId?: string;
|
|
uploading: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const URL_RE = /^https?:\/\/.+/i;
|
|
|
|
function parseRecipients(input: string): Recipient[] {
|
|
return input
|
|
.split(/[,;\n]/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.map((email) => ({ name: '', email }));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
return (
|
|
<View style={styles.chip}>
|
|
<Text style={styles.chipText} numberOfLines={1}>
|
|
{recipient.name || recipient.email}
|
|
</Text>
|
|
<Pressable onPress={onRemove} hitSlop={8}>
|
|
<X size={12} color={c.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SuggestionList({
|
|
suggestions, onPick,
|
|
}: {
|
|
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
|
|
onPick: (s: { name: string; email: string }) => void;
|
|
}) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
return (
|
|
<View style={styles.suggestionBox}>
|
|
{suggestions.map((s, i) => {
|
|
const initials = getContactInitials(s.contact);
|
|
return (
|
|
<Pressable
|
|
key={`${s.contact.id}-${s.email}-${i}`}
|
|
onPress={() => onPick(s)}
|
|
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
|
>
|
|
<View style={styles.suggestionAvatar}>
|
|
<Text style={styles.suggestionAvatarText}>{initials}</Text>
|
|
</View>
|
|
<View style={styles.suggestionText}>
|
|
<Text style={styles.suggestionName} numberOfLines={1}>
|
|
{s.name || s.email}
|
|
</Text>
|
|
{!!s.name && (
|
|
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AttachmentChip({
|
|
attachment, onRemove,
|
|
}: {
|
|
attachment: AttachmentEntry;
|
|
onRemove: () => void;
|
|
}) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
return (
|
|
<View style={styles.attachmentChip}>
|
|
{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
|
|
? attachment.error
|
|
: attachment.uploading
|
|
? 'Uploading...'
|
|
: formatBytes(attachment.size)}
|
|
</Text>
|
|
</View>
|
|
<Pressable onPress={onRemove} hitSlop={8} style={styles.attachmentRemove}>
|
|
<X size={14} color={c.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ToolbarButton({
|
|
active, onPress, icon, disabled,
|
|
}: {
|
|
active?: boolean;
|
|
onPress: () => void;
|
|
icon: React.ReactNode;
|
|
disabled?: boolean;
|
|
}) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
hitSlop={4}
|
|
disabled={disabled}
|
|
style={[styles.formatBtn, active && styles.formatBtnActive, disabled && styles.formatBtnDisabled]}
|
|
>
|
|
{icon}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export default function ComposeScreen({ route, navigation }: Props) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const replyTo = route.params?.replyTo;
|
|
const mode = route.params?.mode ?? 'compose';
|
|
const prefillTo = route.params?.prefillTo;
|
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
|
const sentMailbox = React.useMemo(
|
|
() => mailboxes.find((m) => m.role === 'sent'),
|
|
[mailboxes],
|
|
);
|
|
|
|
const [identities, setIdentities] = React.useState<Identity[]>([]);
|
|
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
|
const [sending, setSending] = React.useState(false);
|
|
|
|
const initialTo = React.useMemo<Recipient[]>(() => {
|
|
if (!replyTo) {
|
|
if (prefillTo && prefillTo.length > 0) {
|
|
return prefillTo
|
|
.filter((r) => !!r.email)
|
|
.map((r) => ({ name: r.name ?? '', email: r.email }));
|
|
}
|
|
return [];
|
|
}
|
|
if (mode === 'forward') return [];
|
|
const base: Recipient[] = replyTo.from.email
|
|
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
|
|
: [];
|
|
if (mode === 'replyAll' && replyTo.to) {
|
|
for (const r of replyTo.to) {
|
|
if (r.email && !base.some((b) => b.email === r.email)) {
|
|
base.push({ name: r.name ?? '', email: r.email });
|
|
}
|
|
}
|
|
}
|
|
return base;
|
|
}, [replyTo, mode, prefillTo]);
|
|
|
|
const initialCc = React.useMemo<Recipient[]>(() => {
|
|
if (mode !== 'replyAll' || !replyTo?.cc) return [];
|
|
return replyTo.cc
|
|
.filter((r) => !!r.email)
|
|
.map((r) => ({ name: r.name ?? '', email: r.email }));
|
|
}, [replyTo, mode]);
|
|
|
|
const initialSubject = React.useMemo(() => {
|
|
if (!replyTo) return '';
|
|
const s = replyTo.subject ?? '';
|
|
if (mode === 'forward') {
|
|
return /^fwd?:/i.test(s) ? s : `Fwd: ${s}`;
|
|
}
|
|
return /^re:/i.test(s) ? s : `Re: ${s}`;
|
|
}, [replyTo, mode]);
|
|
|
|
const initialBodyHtml = React.useMemo(
|
|
() => buildInitialHtml(mode, replyTo
|
|
? {
|
|
from: { name: replyTo.from.name, email: replyTo.from.email },
|
|
to: replyTo.to,
|
|
cc: replyTo.cc,
|
|
subject: replyTo.subject,
|
|
body: replyTo.body,
|
|
}
|
|
: null,
|
|
),
|
|
[mode, replyTo],
|
|
);
|
|
|
|
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
|
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>(initialCc);
|
|
const [ccVisible, setCcVisible] = React.useState(initialCc.length > 0);
|
|
const [toInput, setToInput] = React.useState('');
|
|
const [ccInput, setCcInput] = React.useState('');
|
|
const [subject, setSubject] = React.useState(initialSubject);
|
|
const [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
|
|
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
|
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
|
|
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);
|
|
// 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());
|
|
|
|
const allContacts = useContactsStore((s) => s.contacts);
|
|
const individuals = React.useMemo(() => allContacts.filter((c) => !isGroup(c)), [allContacts]);
|
|
|
|
const suggestionQuery = activeField === 'to' ? toInput : activeField === 'cc' ? ccInput : '';
|
|
const alreadySelected = React.useMemo(
|
|
() => new Set([...toRecipients, ...ccRecipients].map((r) => r.email.toLowerCase())),
|
|
[toRecipients, ccRecipients],
|
|
);
|
|
const suggestions: Array<{ contact: ContactCard; name: string; email: string }> = React.useMemo(() => {
|
|
const q = suggestionQuery.trim();
|
|
if (q.length < 1) return [];
|
|
const out: Array<{ contact: ContactCard; name: string; email: string }> = [];
|
|
for (const c of individuals) {
|
|
if (!matchesContactSearch(c, q)) continue;
|
|
if (!c.emails) continue;
|
|
const name = getContactDisplayName(c);
|
|
for (const e of Object.values(c.emails)) {
|
|
if (!e.address) continue;
|
|
if (alreadySelected.has(e.address.toLowerCase())) continue;
|
|
out.push({ contact: c, name, email: e.address });
|
|
if (out.length >= 8) break;
|
|
}
|
|
if (out.length >= 8) break;
|
|
}
|
|
return out;
|
|
}, [suggestionQuery, individuals, alreadySelected]);
|
|
|
|
const pickSuggestion = (s: { name: string; email: string }) => {
|
|
const recipient: Recipient = { name: s.name, email: s.email };
|
|
if (activeField === 'cc') {
|
|
setCcRecipients((prev) => [...prev, recipient]);
|
|
setCcInput('');
|
|
} else {
|
|
setToRecipients((prev) => [...prev, recipient]);
|
|
setToInput('');
|
|
}
|
|
};
|
|
|
|
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; };
|
|
}, []);
|
|
|
|
const primaryIdentity = identities[0];
|
|
|
|
const commitTyped = () => {
|
|
const extraTo = parseRecipients(toInput);
|
|
const extraCc = parseRecipients(ccInput);
|
|
return {
|
|
finalTo: [...toRecipients, ...extraTo],
|
|
finalCc: [...ccRecipients, ...extraCc],
|
|
};
|
|
};
|
|
|
|
const addTyped = () => {
|
|
const extraTo = parseRecipients(toInput);
|
|
const extraCc = parseRecipients(ccInput);
|
|
if (extraTo.length) {
|
|
setToRecipients((prev) => [...prev, ...extraTo]);
|
|
setToInput('');
|
|
}
|
|
if (extraCc.length) {
|
|
setCcRecipients((prev) => [...prev, ...extraCc]);
|
|
setCcInput('');
|
|
}
|
|
};
|
|
|
|
const { finalTo, finalCc } = commitTyped();
|
|
const hasUploadInFlight = attachments.some((a) => a.uploading);
|
|
const hasUploadError = attachments.some((a) => !!a.error);
|
|
const hasValidRecipients = finalTo.every((r) => EMAIL_RE.test(r.email)) && finalTo.length > 0;
|
|
const bodyPlain = React.useMemo(() => htmlToPlainText(bodyHtml), [bodyHtml]);
|
|
const hasBodyContent = bodyPlain.trim().length > 0
|
|
|| attachments.some((a) => a.blobId && !a.error);
|
|
const canSend =
|
|
!sending &&
|
|
!hasUploadInFlight &&
|
|
!hasUploadError &&
|
|
hasValidRecipients &&
|
|
subject.trim().length > 0 &&
|
|
hasBodyContent &&
|
|
!!primaryIdentity &&
|
|
!!sentMailbox;
|
|
|
|
const onClose = () => {
|
|
const isDirty =
|
|
bodyPlain.trim().length > 0
|
|
|| subject.trim().length > 0
|
|
|| toRecipients.length > 0
|
|
|| ccRecipients.length > 0
|
|
|| attachments.length > 0;
|
|
if (isDirty) {
|
|
Alert.alert(
|
|
t('email_composer.discard_draft_title', 'Discard draft?'),
|
|
t('email_composer.discard_draft_confirm', 'You have unsaved changes. Do you want to discard this draft?'),
|
|
[
|
|
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
|
{
|
|
text: t('email_composer.discard', 'Discard'),
|
|
style: 'destructive',
|
|
onPress: () => navigation.goBack(),
|
|
},
|
|
],
|
|
);
|
|
return;
|
|
}
|
|
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) => {
|
|
setAttachments((prev) => {
|
|
const removed = prev.find((a) => a.localId === localId);
|
|
if (removed?.inline && removed.cid) {
|
|
inlineRegistryRef.current.delete(removed.cid);
|
|
// Strip the editor's <img data-cid="…"> for this cid.
|
|
const escCid = removed.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const stripped = bodyHtml.replace(
|
|
new RegExp(`<img\\b[^>]*\\sdata-cid=("${escCid}"|'${escCid}')[^>]*>`, 'gi'),
|
|
'',
|
|
);
|
|
if (stripped !== bodyHtml) {
|
|
setBodyHtml(stripped);
|
|
editorRef.current?.setHtml(stripped);
|
|
}
|
|
}
|
|
return prev.filter((a) => a.localId !== localId);
|
|
});
|
|
};
|
|
|
|
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,
|
|
};
|
|
setAttachments((prev) => [...prev, entry]);
|
|
if (asset.inline && asset.cid) inlineRegistryRef.current.set(asset.cid, entry);
|
|
return entry;
|
|
};
|
|
|
|
const startUpload = async (entry: AttachmentEntry) => {
|
|
try {
|
|
const { blobId, size, type } = await uploadBlob(entry.uri, entry.type);
|
|
updateAttachment(entry.localId, {
|
|
blobId,
|
|
type: type || entry.type,
|
|
size: size || entry.size,
|
|
uploading: false,
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'Upload failed';
|
|
updateAttachment(entry.localId, { uploading: false, error: message });
|
|
Alert.alert(
|
|
t('email_composer.upload_failed', 'Failed to upload {filename}').replace('{filename}', entry.name),
|
|
message,
|
|
);
|
|
}
|
|
};
|
|
|
|
const pickAttachment = async () => {
|
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert(
|
|
t('email_composer.attach', 'Attach'),
|
|
'Photo library permission is required to attach images.',
|
|
);
|
|
return;
|
|
}
|
|
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'}`;
|
|
const entry = addUploadEntry({
|
|
name: fallbackName,
|
|
type: asset.mimeType ?? 'application/octet-stream',
|
|
size: asset.fileSize ?? 0,
|
|
uri: asset.uri,
|
|
inline: false,
|
|
});
|
|
void startUpload(entry);
|
|
}
|
|
};
|
|
|
|
const insertInlineImage = async () => {
|
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert(
|
|
t('email_composer.attach', 'Attach'),
|
|
'Photo library permission is required to attach images.',
|
|
);
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ['images'],
|
|
allowsMultipleSelection: false,
|
|
quality: 0.9,
|
|
exif: false,
|
|
});
|
|
if (result.canceled || !result.assets[0]) return;
|
|
const asset = result.assets[0];
|
|
const mime = asset.mimeType ?? 'image/jpeg';
|
|
const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`;
|
|
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'), 'Could not load image');
|
|
return;
|
|
}
|
|
editorRef.current?.insertImage(dataUrl, cid, fallbackName);
|
|
const entry = addUploadEntry({
|
|
name: fallbackName,
|
|
type: mime,
|
|
size: asset.fileSize ?? 0,
|
|
uri: asset.uri,
|
|
inline: true,
|
|
cid,
|
|
});
|
|
void startUpload(entry);
|
|
};
|
|
|
|
// ── 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();
|
|
};
|
|
|
|
// ── Send ─────────────────────────────────────────────────────────────
|
|
const onSend = async () => {
|
|
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
|
setSending(true);
|
|
try {
|
|
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
|
|
|
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml);
|
|
// Belt-and-suspenders sanitization: the editor uses execCommand which
|
|
// can preserve pasted <script>/<style>/etc. Strip them before sending.
|
|
const safeHtml = stripDangerousTags(rewrittenHtml);
|
|
const finalHtml = `<div>${safeHtml}</div>`;
|
|
const finalText = htmlToPlainText(safeHtml);
|
|
|
|
const inlineFromBody = usedCids
|
|
.map((cid) => inlineRegistryRef.current.get(cid))
|
|
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
|
|
.map<OutgoingAttachment>((e) => ({
|
|
blobId: 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: a.blobId!,
|
|
type: a.type,
|
|
name: a.name,
|
|
size: a.size,
|
|
disposition: 'attachment',
|
|
}));
|
|
|
|
const outgoing = [...inlineFromBody, ...fileAttachments];
|
|
|
|
await sendEmail(
|
|
{
|
|
from,
|
|
to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })),
|
|
cc: finalCc.length
|
|
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
|
: undefined,
|
|
subject,
|
|
htmlBody: finalHtml,
|
|
textBody: finalText,
|
|
attachments: outgoing.length ? outgoing : undefined,
|
|
inReplyTo: replyTo?.inReplyTo,
|
|
references: replyTo?.references,
|
|
},
|
|
primaryIdentity.id,
|
|
sentMailbox.id,
|
|
);
|
|
navigation.goBack();
|
|
} catch (e) {
|
|
Alert.alert(
|
|
t('email_composer.send_failed', 'Send failed'),
|
|
e instanceof Error ? e.message : 'Failed to send email',
|
|
);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const titleKey =
|
|
mode === 'forward' ? 'email_composer.forward'
|
|
: mode === 'replyAll' ? 'email_composer.reply_all'
|
|
: replyTo ? 'email_composer.reply'
|
|
: 'email_composer.new_message';
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
<View style={styles.header}>
|
|
<Pressable onPress={onClose} style={styles.headerBtn}>
|
|
<X size={22} color={c.text} />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>
|
|
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
|
|
</Text>
|
|
<View style={styles.headerRight}>
|
|
<Pressable
|
|
onPress={() => { void pickAttachment(); }}
|
|
style={styles.headerBtn}
|
|
hitSlop={8}
|
|
>
|
|
<Paperclip size={20} color={c.text} />
|
|
</Pressable>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onPress={() => { void 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>
|
|
|
|
<KeyboardAvoidingView
|
|
style={styles.flex}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<ScrollView
|
|
style={styles.flex}
|
|
keyboardShouldPersistTaps="handled"
|
|
contentContainerStyle={styles.scrollContent}
|
|
>
|
|
<View style={styles.fieldRow}>
|
|
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
|
|
<View style={styles.fieldContent}>
|
|
<Text style={styles.fromText} numberOfLines={1}>
|
|
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
|
|
</Text>
|
|
<ChevronDown size={14} color={c.textMuted} />
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.fieldRow}>
|
|
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
|
|
<View style={styles.recipientField}>
|
|
{toRecipients.map((r, i) => (
|
|
<RecipientChip
|
|
key={i}
|
|
recipient={r}
|
|
onRemove={() => setToRecipients((prev) => prev.filter((_, idx) => idx !== i))}
|
|
/>
|
|
))}
|
|
<TextInput
|
|
style={styles.recipientInput}
|
|
placeholder={t('email_composer.to_placeholder', 'Recipient email addresses')}
|
|
placeholderTextColor={c.textMuted}
|
|
value={toInput}
|
|
onChangeText={setToInput}
|
|
onFocus={() => setActiveField('to')}
|
|
onBlur={() => {
|
|
addTyped();
|
|
setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200);
|
|
}}
|
|
onSubmitEditing={addTyped}
|
|
keyboardType="email-address"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
{activeField === 'to' && suggestions.length > 0 && (
|
|
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
|
)}
|
|
{!ccVisible && (
|
|
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
|
|
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
|
|
{ccVisible && (
|
|
<View style={styles.fieldRow}>
|
|
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
|
|
<View style={styles.recipientField}>
|
|
{ccRecipients.map((r, i) => (
|
|
<RecipientChip
|
|
key={i}
|
|
recipient={r}
|
|
onRemove={() => setCcRecipients((prev) => prev.filter((_, idx) => idx !== i))}
|
|
/>
|
|
))}
|
|
<TextInput
|
|
style={styles.recipientInput}
|
|
placeholder={t('email_composer.cc_placeholder', 'Cc recipients')}
|
|
placeholderTextColor={c.textMuted}
|
|
value={ccInput}
|
|
onChangeText={setCcInput}
|
|
onFocus={() => setActiveField('cc')}
|
|
onBlur={() => {
|
|
addTyped();
|
|
setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200);
|
|
}}
|
|
onSubmitEditing={addTyped}
|
|
keyboardType="email-address"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
{activeField === 'cc' && suggestions.length > 0 && (
|
|
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.fieldRow}>
|
|
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
|
|
<TextInput
|
|
style={styles.subjectInput}
|
|
placeholder={t('email_composer.subject_placeholder', 'Subject')}
|
|
placeholderTextColor={c.textMuted}
|
|
value={subject}
|
|
onChangeText={setSubject}
|
|
/>
|
|
</View>
|
|
|
|
{attachments.length > 0 && (
|
|
<View style={styles.attachmentList}>
|
|
{attachments.map((a) => (
|
|
<AttachmentChip
|
|
key={a.localId}
|
|
attachment={a}
|
|
onRemove={() => removeAttachment(a.localId)}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
<RichTextEditor
|
|
ref={editorRef}
|
|
initialHtml={initialBodyHtml}
|
|
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
|
onChange={setBodyHtml}
|
|
onSelectionChange={setSelState}
|
|
/>
|
|
</ScrollView>
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.formatBar}
|
|
contentContainerStyle={styles.formatActions}
|
|
keyboardShouldPersistTaps="always"
|
|
>
|
|
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
|
|
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
|
|
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
|
|
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
|
|
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
|
|
|
|
<View style={styles.formatSep} />
|
|
|
|
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
|
|
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
|
|
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
|
|
|
|
<View style={styles.formatSep} />
|
|
|
|
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
|
|
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
|
|
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
|
|
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
|
|
|
|
<View style={styles.formatSep} />
|
|
|
|
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
|
|
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
|
|
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
|
|
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
|
|
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
|
|
|
|
<View style={styles.formatSep} />
|
|
|
|
<ToolbarButton active={selState.link} onPress={onLinkPress}
|
|
icon={selState.link
|
|
? <Link2Off size={18} color={c.primary} />
|
|
: <Link2 size={18} color={c.textSecondary} />} />
|
|
<ToolbarButton onPress={() => { void insertInlineImage(); }}
|
|
icon={<ImageIcon size={18} color={c.textSecondary} />} />
|
|
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
|
|
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
|
|
|
|
<View style={styles.formatSep} />
|
|
|
|
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
|
|
icon={<Undo2 size={18} color={c.textSecondary} />} />
|
|
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
|
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
|
|
<Modal
|
|
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>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
function makeStyles(c: ThemePalette) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: c.background },
|
|
flex: { flex: 1 },
|
|
scrollContent: { flexGrow: 1 },
|
|
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: c.border,
|
|
gap: spacing.sm,
|
|
},
|
|
headerBtn: {
|
|
width: componentSizes.buttonLg,
|
|
height: componentSizes.buttonLg,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: radius.full,
|
|
},
|
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
|
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,
|
|
},
|
|
fieldLabel: {
|
|
...typography.body,
|
|
color: c.textMuted,
|
|
width: 56,
|
|
paddingTop: 10,
|
|
},
|
|
fieldContent: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 10,
|
|
gap: spacing.sm,
|
|
},
|
|
fromText: { ...typography.body, color: c.text },
|
|
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,
|
|
},
|
|
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
|
recipientInput: {
|
|
flexGrow: 1,
|
|
minWidth: 100,
|
|
...typography.body,
|
|
color: c.text,
|
|
paddingVertical: 6,
|
|
},
|
|
suggestionBox: {
|
|
marginTop: spacing.xs,
|
|
marginLeft: 60,
|
|
borderWidth: 1,
|
|
borderColor: c.borderLight,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: c.card,
|
|
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,
|
|
},
|
|
|
|
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 },
|
|
|
|
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 },
|
|
});
|
|
}
|