import React from 'react'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ArrowLeft, Share2, Copy, Check } from 'lucide-react-native'; import * as Clipboard from 'expo-clipboard'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; import { fetchRawEmail, shareEmailEml } from '../lib/email-export'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useLocaleStore } from '../stores/locale-store'; type Props = NativeStackScreenProps; // A single holding a multi-megabyte string stalls the JS thread and // the native text layout; anything past this is offered via share instead. const MAX_DISPLAY_BYTES = 1024 * 1024; export default function EmailSourceScreen({ route, navigation }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); // Blobs are account-scoped, so a message from a shared/group mailbox has // to be fetched against its owning account. const { blobId, subject, jmapAccountId } = route.params; const [raw, setRaw] = React.useState(null); const [error, setError] = React.useState(null); const [copied, setCopied] = React.useState(false); React.useEffect(() => { let cancelled = false; void (async () => { try { const text = await fetchRawEmail(blobId, jmapAccountId); if (!cancelled) setRaw(text); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : t('email_viewer.load_failed', 'Failed to load email source')); } })(); return () => { cancelled = true; }; }, [blobId, jmapAccountId, t]); const truncated = !!raw && raw.length > MAX_DISPLAY_BYTES; const shown = React.useMemo(() => (raw && truncated ? raw.slice(0, MAX_DISPLAY_BYTES) : raw), [raw, truncated]); const onShare = async () => { try { await shareEmailEml(blobId, undefined, subject, jmapAccountId); } catch (e) { Alert.alert(t('email_viewer.export_failed', 'Share failed'), e instanceof Error ? e.message : String(e)); } }; const onCopy = async () => { if (!raw) return; try { await Clipboard.setStringAsync(raw); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (e) { Alert.alert(t('common.error', 'Error'), e instanceof Error ? e.message : String(e)); } }; return ( navigation.goBack()} style={styles.headerBtn} hitSlop={8}> {subject || t('email_viewer.email_source', 'Email Source')} {copied ? : } {!raw && !error ? ( ) : error ? ( {error} ) : ( {truncated && ( {t('email_viewer.source_too_large', 'The message is too large to display in full. Share the full source instead.')} {t('email_viewer.export_email', 'Export as .eml')} )} {shown} )} ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, header: { flexDirection: 'row', alignItems: 'center', height: componentSizes.headerHeight, paddingHorizontal: spacing.lg, borderBottomWidth: 1, borderBottomColor: c.border, gap: spacing.sm, }, headerBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md, }, headerTitle: { ...typography.h3, color: c.text, flex: 1 }, loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, error: { ...typography.body, color: c.error, paddingHorizontal: spacing.lg, textAlign: 'center' }, body: { padding: spacing.lg }, notice: { marginBottom: spacing.md, padding: spacing.md, borderRadius: radius.md, backgroundColor: c.warningBg, gap: spacing.xs, }, noticeText: { ...typography.caption, color: c.warning }, noticeAction: { ...typography.caption, color: c.primary, fontWeight: '600' }, source: { fontFamily: 'monospace', fontSize: 11, lineHeight: 16, color: c.text, }, }); }