diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index d786211c..890e1fb4 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -1,20 +1,37 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator, + Modal, useWindowDimensions, Animated, Easing, } 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, + ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen, + FolderInput, ShieldAlert, ShieldCheck, X, Check, + Inbox, Send, File as FileIcon, Ban, Folder, } 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 type { Email } from '../api/types'; +import { setEmailKeywords } from '../api/email'; +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 { @@ -43,16 +60,19 @@ function plainTextBody(email: Email): string { 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 toggleStar = useEmailStore((s) => s.toggleStar); 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 [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; @@ -78,16 +98,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) { }, [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; - void toggleStar(email.id, !starred); - setEmail({ - ...email, - keywords: { - ...email.keywords, - ...(starred ? {} : { $flagged: true }), - }, - }); + 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 = () => { @@ -98,6 +148,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) { 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]; @@ -119,6 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) { 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 */} @@ -132,6 +216,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) { label="Delete" onPress={onDelete} /> + {showArchive && ( + } + label="Archive" + onPress={onArchive} + /> + )} + {showMarkUnread && ( + + ) : ( + + ) + } + label={unread ? 'Read' : 'Unread'} + onPress={onToggleUnread} + /> + )} } label="More" + onPress={() => setMoreMenuOpen(true)} /> @@ -262,10 +367,243 @@ export default function EmailThreadScreen({ route, navigation }: Props) { ) : null} + + setMoreMenuOpen(false)} + unread={unread} + canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id} + canMarkUnread={!showMarkUnread} + canMove={mailboxes.length > 0} + showSpam={!!junkMailbox || isInJunk} + isInJunk={isInJunk} + onArchive={() => { setMoreMenuOpen(false); onArchive(); }} + onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }} + onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }} + onToggleSpam={onToggleSpam} + /> + + 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; + onArchive: () => void; + onToggleUnread: () => void; + onMove: () => void; + onToggleSpam: () => void; +} + +function MoreMenuSheet({ + visible, onClose, unread, canArchive, canMarkUnread, canMove, + showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam, +}: 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} + /> + )} + + + ); +} + +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 }) { @@ -324,7 +662,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', gap: 2, - paddingHorizontal: spacing.md, + paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, minHeight: 44, }, @@ -487,4 +825,94 @@ const styles = StyleSheet.create({ 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, + }, });