import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator, Modal, useWindowDimensions, Animated, Easing, Alert, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward, ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen, FolderInput, ShieldAlert, ShieldCheck, X, Check, Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, } from 'lucide-react-native'; import type { LucideIcon } from 'lucide-react-native'; import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; import EmailBodyView from '../components/EmailBodyView'; import SenderAvatar from '../components/SenderAvatar'; import { useEmailStore } from '../stores/email-store'; import { setEmailKeywords } from '../api/email'; import { shareEmailEml } from '../lib/email-export'; import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; import type { Email, Mailbox } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { const lower = name.toLowerCase(); if (role === 'inbox' || lower.includes('inbox')) return Inbox; if (role === 'sent' || lower.includes('sent')) return Send; if (role === 'drafts' || lower.includes('draft')) return FileIcon; if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; if (role === 'archive' || lower.includes('archive')) return Archive; return Folder; } type Props = NativeStackScreenProps; function formatHeaderDate(iso: string): string { const d = new Date(iso); return d.toLocaleString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } function formatSize(bytes?: number): string { if (!bytes) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } function plainTextBody(email: Email): string { const textPart = email.textBody?.[0]; if (textPart?.partId && email.bodyValues?.[textPart.partId]?.value) { return email.bodyValues[textPart.partId].value; } return email.preview ?? ''; } export default function EmailThreadScreen({ route, navigation }: Props) { const { emailId, subject: subjectParam } = route.params; const insets = useSafeAreaInsets(); const { width: windowWidth } = useWindowDimensions(); const getEmailDetail = useEmailStore((s) => s.getEmailDetail); const markRead = useEmailStore((s) => s.markRead); const deleteEmail = useEmailStore((s) => s.deleteEmail); const moveToMailbox = useEmailStore((s) => s.moveToMailbox); const mailboxes = useEmailStore((s) => s.mailboxes); const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const emails = useEmailStore((s) => s.emails); const currentIndex = emails.findIndex((e) => e.id === emailId); const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null; const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null; const goToEmail = (target: { id: string; threadId: string; subject: string }) => { navigation.replace('EmailThread', { emailId: target.id, threadId: target.threadId, subject: target.subject, }); }; const [email, setEmail] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [moreMenuOpen, setMoreMenuOpen] = React.useState(false); const [moveMenuOpen, setMoveMenuOpen] = React.useState(false); React.useEffect(() => { let cancelled = false; setLoading(true); setError(null); void (async () => { try { const fetched = await getEmailDetail(emailId); if (cancelled) return; setEmail(fetched); if (fetched && !fetched.keywords?.$seen) { void markRead(emailId); } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load email'); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [emailId, getEmailDetail, markRead]); const starred = !!email?.keywords?.$flagged; const unread = !!email && !email.keywords?.$seen; const archiveMailbox = React.useMemo( () => mailboxes.find((m) => m.role === 'archive'), [mailboxes], ); const junkMailbox = React.useMemo( () => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'), [mailboxes], ); const inboxMailbox = React.useMemo( () => mailboxes.find((m) => m.role === 'inbox'), [mailboxes], ); const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]); const updateLocalKeywords = (next: Record) => { setEmail((prev) => (prev ? { ...prev, keywords: next } : prev)); }; const onToggleStar = () => { if (!email) return; const next = { ...email.keywords }; if (starred) delete next.$flagged; else next.$flagged = true; updateLocalKeywords(next); void setEmailKeywords(email.id, next); }; const onToggleUnread = () => { if (!email) return; if (unread) { void markRead(email.id); updateLocalKeywords({ ...email.keywords, $seen: true }); } else { const next = { ...email.keywords }; delete next.$seen; updateLocalKeywords(next); void setEmailKeywords(email.id, next); } }; const onDelete = () => { if (!email || !currentMailboxId) return; const trash = mailboxes.find((m) => m.role === 'trash'); if (!trash) return; void deleteEmail(email.id, trash.id, currentMailboxId); navigation.goBack(); }; const onArchive = () => { if (!email || !currentMailboxId || !archiveMailbox) return; if (currentMailboxId === archiveMailbox.id) return; void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id); navigation.goBack(); }; const onToggleSpam = () => { if (!email || !currentMailboxId) return; setMoreMenuOpen(false); if (isInJunk) { const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId); if (!target) return; void moveToMailbox(email.id, currentMailboxId, target.id); } else { if (!junkMailbox) return; void moveToMailbox(email.id, currentMailboxId, junkMailbox.id); } navigation.goBack(); }; const onMoveToMailbox = (toId: string) => { if (!email || !currentMailboxId || toId === currentMailboxId) return; setMoveMenuOpen(false); setMoreMenuOpen(false); void moveToMailbox(email.id, currentMailboxId, toId); navigation.goBack(); }; const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => { if (!email) return; const from = email.from?.[0]; if (!from && mode !== 'forward') return; navigation.navigate('Compose', { mode, replyTo: { from: from ?? { email: '' }, to: email.to, cc: email.cc, subject: email.subject ?? '', body: plainTextBody(email), inReplyTo: email.id, }, }); }; const subject = email?.subject ?? subjectParam ?? '(no subject)'; const from = email?.from?.[0]; const bottomBarHeight = 60 + Math.max(insets.bottom, 4); // Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding); // back arrow + side padding reserves ~64px. Drop optional buttons on narrow screens. const showMarkUnread = windowWidth >= 340; const showArchive = windowWidth >= 400 && !!archiveMailbox; return ( {/* Toolbar */} navigation.goBack()} style={styles.toolbarBack} hitSlop={8}> } label="Delete" onPress={onDelete} /> {showArchive && ( } label="Archive" onPress={onArchive} /> )} {showMarkUnread && ( ) : ( ) } label={unread ? 'Read' : 'Unread'} onPress={onToggleUnread} /> )} } label="Star" onPress={onToggleStar} /> } label="More" onPress={() => setMoreMenuOpen(true)} /> {loading ? ( ) : error ? ( {error} ) : email ? ( <> {/* Subject block */} {subject} {formatHeaderDate(email.receivedAt)} {email.size > 0 && ( {formatSize(email.size)} )} {/* Sender info */} {from?.name || from?.email || 'Unknown sender'} {from?.name && from?.email ? ( {from.email} ) : null} to {email.to?.map((t) => t.name || t.email).join(', ') || '-'} {/* Attachments chips */} {email.attachments && email.attachments.length > 0 && ( {email.attachments.map((att, idx) => ( {att.name || 'attachment'} {formatSize(att.size)} ))} )} {/* Body */} {/* Bottom action bar */} } label="Prev" onPress={prevEmail ? () => goToEmail(prevEmail) : undefined} disabled={!prevEmail} /> } label="Reply" onPress={() => navigateCompose('reply')} /> } label="Reply All" onPress={() => navigateCompose('replyAll')} /> } label="Forward" onPress={() => navigateCompose('forward')} /> } label="Next" onPress={nextEmail ? () => goToEmail(nextEmail) : undefined} disabled={!nextEmail} /> ) : null} setMoreMenuOpen(false)} unread={unread} canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id} canMarkUnread={true} canMove={mailboxes.length > 0} showSpam={!!junkMailbox || isInJunk} isInJunk={isInJunk} canViewSource={!!email?.blobId} canExport={!!email?.blobId} onArchive={() => { setMoreMenuOpen(false); onArchive(); }} onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }} onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }} onToggleSpam={onToggleSpam} onViewSource={() => { setMoreMenuOpen(false); if (email?.blobId) { navigation.navigate('EmailSource', { emailId: email.id, blobId: email.blobId, subject: email.subject, }); } }} onExport={async () => { setMoreMenuOpen(false); if (!email?.blobId) return; try { await shareEmailEml(email.blobId, email.subject); } catch (e) { Alert.alert('Export failed', e instanceof Error ? e.message : String(e)); } }} /> setMoveMenuOpen(false)} mailboxes={mailboxes} currentMailboxId={currentMailboxId} onPick={onMoveToMailbox} /> ); } interface MoreMenuSheetProps { visible: boolean; onClose: () => void; unread: boolean; canArchive: boolean; canMarkUnread: boolean; canMove: boolean; showSpam: boolean; isInJunk: boolean; canViewSource: boolean; canExport: boolean; onArchive: () => void; onToggleUnread: () => void; onMove: () => void; onToggleSpam: () => void; onViewSource: () => void; onExport: () => void; } function MoreMenuSheet({ visible, onClose, unread, canArchive, canMarkUnread, canMove, showSpam, isInJunk, canViewSource, canExport, onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport, }: MoreMenuSheetProps) { const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(400)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { if (visible) { Animated.parallel([ Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), ]).start(); } else { Animated.parallel([ Animated.timing(slideY, { toValue: 400, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), ]).start(); } }, [visible, slideY, overlayOpacity]); return ( More actions {canArchive && ( } label="Archive" onPress={onArchive} /> )} {canMarkUnread && ( ) : ( ) } label={unread ? 'Mark as read' : 'Mark as unread'} onPress={onToggleUnread} /> )} {canMove && ( } label="Move to folder…" onPress={onMove} trailing={} /> )} {showSpam && ( ) : ( ) } label={isInJunk ? 'Not spam' : 'Mark as spam'} onPress={onToggleSpam} /> )} {canViewSource && ( } label="View source" onPress={onViewSource} /> )} {canExport && ( } label="Export email (.eml)" onPress={onExport} /> )} ); } interface MoveMenuSheetProps { visible: boolean; onClose: () => void; mailboxes: Mailbox[]; currentMailboxId: string | null; onPick: (id: string) => void; } function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) { const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { if (visible) { Animated.parallel([ Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), ]).start(); } else { Animated.parallel([ Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), ]).start(); } }, [visible, slideY, overlayOpacity]); const visibleNodes = React.useMemo(() => { const tree = buildMailboxTree(mailboxes); const expanded = new Set(); const collect = (nodes: MailboxNode[]) => { for (const n of nodes) { if (n.children.length > 0) { expanded.add(n.id); collect(n.children); } } }; collect(tree); return flattenVisible(tree, expanded); }, [mailboxes]); return ( Move to folder {visibleNodes.map((node) => { const Icon = moveTargetIcon(node.role, node.name); const isCurrent = node.id === currentMailboxId; const canTarget = node.myRights?.mayAddItems !== false && !isCurrent; return ( onPick(node.id) : undefined} disabled={!canTarget} style={({ pressed }) => [ styles.moveRow, pressed && canTarget && styles.moveRowPressed, { paddingLeft: spacing.lg + node.depth * 16 }, ]} > {node.name} {isCurrent && } ); })} ); } function MoreMenuItem({ icon, label, onPress, trailing, }: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) { return ( [styles.moreItem, pressed && styles.moreItemPressed]} > {icon} {label} {trailing} ); } function ToolbarButton({ icon, label, onPress, }: { icon: React.ReactNode; label: string; onPress?: () => void }) { return ( {icon} {label} ); } function BottomBarButton({ icon, label, onPress, disabled, }: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) { return ( {icon} {label} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background }, // Top toolbar toolbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.sm, paddingTop: spacing.xs, paddingBottom: spacing.xs, backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border, }, toolbarBack: { width: componentSizes.avatarSm, height: componentSizes.avatarSm, alignItems: 'center', justifyContent: 'center', borderRadius: radius.full, }, toolbarActions: { flexDirection: 'row', alignItems: 'center', }, toolbarAction: { alignItems: 'center', justifyContent: 'center', gap: 2, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, minHeight: 44, }, toolbarActionLabel: { ...typography.small, color: colors.textSecondary, }, // Content centered: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg, }, errorText: { ...typography.body, color: colors.error, textAlign: 'center' }, scroll: { flex: 1, backgroundColor: colors.background }, // Subject block subjectBlock: { backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border, paddingHorizontal: spacing.lg, paddingVertical: spacing.md, }, subjectRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, }, subjectMain: { flex: 1, flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, }, subjectStar: { paddingTop: 4, }, subjectText: { flex: 1, fontSize: 20, fontWeight: '700', lineHeight: 28, color: colors.text, letterSpacing: -0.2, }, subjectMeta: { alignItems: 'flex-end', }, subjectDate: { ...typography.caption, color: colors.textSecondary, }, subjectSize: { ...typography.caption, color: colors.textMuted, marginTop: 2, }, // Sender block senderBlock: { backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border, paddingHorizontal: spacing.lg, paddingVertical: spacing.md, }, senderRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, }, senderInfo: { flex: 1, minWidth: 0 }, senderName: { ...typography.bodySemibold, color: colors.text, }, senderEmail: { ...typography.caption, color: colors.textSecondary, marginTop: 2, }, senderRecipients: { ...typography.caption, color: colors.textSecondary, marginTop: 4, }, senderRecipientsLabel: { color: colors.textMuted, }, // Attachments attachmentsBlock: { backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, attachmentsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, }, attachmentChip: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, paddingVertical: 6, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.sm, }, attachmentName: { ...typography.caption, color: colors.text, maxWidth: 180, }, attachmentSize: { ...typography.small, color: colors.textMuted, }, // Body bodyBlock: { backgroundColor: colors.background, }, // Bottom bar bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'stretch', backgroundColor: colors.background, borderTopWidth: 1, borderTopColor: colors.border, }, bottomBarBtn: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.sm, gap: 4, minHeight: 44, }, bottomBarBtnDisabled: { opacity: 0.4, }, bottomBarLabel: { ...typography.small, color: colors.textSecondary, }, bottomBarLabelDisabled: { color: colors.textMuted, }, // Bottom sheet (More menu / Move folder picker) sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', }, sheetOverlayPress: { flex: 1 }, sheet: { position: 'absolute', left: 0, right: 0, bottom: 0, backgroundColor: colors.popover, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg, borderTopWidth: 1, borderColor: colors.border, paddingTop: spacing.sm, }, sheetTall: { maxHeight: '75%', }, sheetHandle: { alignSelf: 'center', width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border, marginBottom: spacing.sm, }, sheetHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border, }, sheetTitle: { ...typography.bodySemibold, color: colors.text, }, sheetClose: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs, }, // More menu item moreItem: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.md, minHeight: 48, }, moreItemPressed: { backgroundColor: colors.surfaceHover }, moreItemIcon: { width: 20, alignItems: 'center', justifyContent: 'center', }, moreItemLabel: { ...typography.body, color: colors.text, flex: 1, }, // Move folder row moveRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingRight: spacing.lg, paddingVertical: spacing.sm + 2, minHeight: 44, }, moveRowPressed: { backgroundColor: colors.surfaceHover }, moveRowLabel: { ...typography.body, color: colors.text, flex: 1, }, moveRowLabelDisabled: { color: colors.textMuted, }, });