import React from 'react'; import { View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, } from 'lucide-react-native'; import type { LucideIcon } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useEmailStore } from '../stores/email-store'; import { useAuthStore } from '../stores/auth-store'; import { useAccountStore } from '../stores/account-store'; import { MAX_ACCOUNTS } from '../lib/account-utils'; import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; import { generateAvatarColor, getAccountInitials } from '../lib/avatar-utils'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; const ROW_PX_BASE = 8; const STORAGE_KEYS = { expanded: 'sidebar:expandedMailboxes', foldersExpanded: 'sidebar:foldersExpanded', }; function iconFor( role: string | null | undefined, name: string | undefined, hasChildren: boolean, isExpanded: boolean, ): 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; if (lower.includes('star') || lower.includes('flag')) return Star; if (hasChildren) return isExpanded ? FolderOpen : Folder; return Folder; } const ROLE_COLOR_FIXED: Record = { inbox: '#60a5fa', sent: '#4ade80', drafts: '#a78bfa', junk: '#f87171', archive: '#fbbf24', }; function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string { if (role === 'trash') return c.textMuted; if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role]; return isSelected ? c.text : c.textSecondary; } function RowCounts({ unread, total }: { unread: number; total: number }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); if (unread === 0 && total === 0) return null; return ( {unread > 0 && {unread}} {unread > 0 && total > 0 && /} {total > 0 && {total}} ); } interface SidebarRowProps { icon: React.ReactElement; label: string; depth: number; isSelected: boolean; unread: number; total: number; hasChildren: boolean; isExpanded: boolean; onPress: () => void; onToggleExpand: () => void; } function SidebarRow({ icon, label, depth, isSelected, unread, total, hasChildren, isExpanded, onPress, onToggleExpand, }: SidebarRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const leftPad = ROW_PX_BASE + depth * INDENT_STEP; return ( [ styles.row, isSelected && styles.rowSelected, pressed && !isSelected && styles.rowPressed, ]} > {hasChildren ? ( {isExpanded ? ( ) : ( )} ) : ( )} {icon} {label} ); } interface SidebarDrawerProps { visible: boolean; onClose: () => void; } export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const mailboxes = useEmailStore((s) => s.mailboxes); const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const selectMailbox = useEmailStore((s) => s.selectMailbox); const username = useAuthStore((s) => s.username); const serverUrl = useAuthStore((s) => s.serverUrl); const logout = useAuthStore((s) => s.logout); const logoutAll = useAuthStore((s) => s.logoutAll); const switchAccount = useAuthStore((s) => s.switchAccount); const activeAccountId = useAuthStore((s) => s.activeAccountId); const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); const navigation = useNavigation>(); const [foldersExpanded, setFoldersExpanded] = React.useState(true); const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set()); const [accountMenuOpen, setAccountMenuOpen] = React.useState(false); React.useEffect(() => { void (async () => { try { const [rawExp, rawFld] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.expanded), AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded), ]); if (rawExp) { try { const ids = JSON.parse(rawExp) as string[]; if (Array.isArray(ids)) setExpandedFolders(new Set(ids)); } catch { /* ignore */ } } else { const tree = buildMailboxTree(mailboxes); const collect = (nodes: MailboxNode[]): string[] => { const ids: string[] = []; for (const n of nodes) { if (n.children.length > 0) { ids.push(n.id); ids.push(...collect(n.children)); } } return ids; }; setExpandedFolders(new Set(collect(tree))); } if (rawFld != null) setFoldersExpanded(rawFld === 'true'); } catch { /* ignore */ } })(); }, [mailboxes]); const tree = React.useMemo(() => buildMailboxTree(mailboxes), [mailboxes]); const visibleNodes = React.useMemo( () => flattenVisible(tree, expandedFolders), [tree, expandedFolders], ); const persistExpanded = React.useCallback(async (next: Set) => { try { await AsyncStorage.setItem(STORAGE_KEYS.expanded, JSON.stringify(Array.from(next))); } catch { /* ignore */ } }, []); const toggleExpand = React.useCallback((id: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); void persistExpanded(next); return next; }); }, [persistExpanded]); const toggleFoldersSection = React.useCallback(() => { setFoldersExpanded((prev) => { const next = !prev; void AsyncStorage.setItem(STORAGE_KEYS.foldersExpanded, String(next)).catch(() => {}); return next; }); }, []); const handleSelect = React.useCallback((id: string) => { void selectMailbox(id); onClose(); }, [selectMailbox, onClose]); const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { if (visible) { Animated.parallel([ Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }), ]).start(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }), ]).start(); } }, [visible, slideX, overlayOpacity]); const accountEmail = username || ''; const initials = React.useMemo( () => getAccountInitials('', accountEmail), [accountEmail], ); const avatarBg = React.useMemo( () => (accountEmail ? generateAvatarColor(accountEmail) : c.primary), [accountEmail], ); const hostname = React.useMemo(() => { if (!serverUrl) return ''; try { return new URL(serverUrl).hostname; } catch { return serverUrl; } }, [serverUrl]); const accountEmailFull = React.useMemo(() => { if (!username) return hostname; if (username.includes('@')) return username; return hostname ? `${username}@${hostname}` : username; }, [username, hostname]); return ( {/* Header: close + account switcher */} setAccountMenuOpen((v) => !v)} style={({ pressed }) => [styles.account, pressed && styles.accountPressed]} > {initials} {username || 'Account'} {hostname ? ( {hostname} ) : null} {accountMenuOpen && ( {accounts.map((acc) => { const isActive = acc.id === activeAccountId; let accHost = ''; try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } const accEmail = acc.email || (acc.username.includes('@') ? acc.username : accHost ? `${acc.username}@${accHost}` : acc.username); return ( { setAccountMenuOpen(false); onClose(); void switchAccount(acc.id); }} style={({ pressed }) => [ styles.accountMenuRow, isActive && styles.accountMenuRowActive, pressed && !isActive && styles.accountMenuActionPressed, ]} > {getAccountInitials(acc.displayName, acc.email || acc.username)} {isActive && ( )} {acc.displayName || acc.username} {acc.isDefault && ( )} {accEmail} {accHost} ); })} {accounts.length < MAX_ACCOUNTS && ( <> [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); onClose(); navigation.navigate('AddAccount'); }} > Add account )} {accounts.length > 1 && activeAccountId && (() => { const active = accounts.find((a) => a.id === activeAccountId); if (!active || active.isDefault) return null; return ( [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => setDefaultAccount(active.id)} > Set as default ); })()} [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }} > Sign out of {accountEmailFull || 'account'} {accounts.length > 1 && ( [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }} > Sign out all )} )} {/* Folders section */} {foldersExpanded ? ( ) : ( )} Folders {foldersExpanded && ( mailboxes.length === 0 ? ( Loading mailboxes… ) : ( visibleNodes.map((node) => { const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); const Icon = iconFor(node.role, node.name, hasChildren, isExpanded); const isSelected = node.id === currentMailboxId; return ( } label={node.name} depth={node.depth} isSelected={isSelected} unread={node.unreadEmails} total={node.totalEmails} hasChildren={hasChildren} isExpanded={isExpanded} onPress={() => handleSelect(node.id)} onToggleExpand={() => toggleExpand(node.id)} /> ); }) ) )} ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', }, overlayPress: { flex: 1 }, drawer: { position: 'absolute', top: 0, bottom: 0, left: 0, width: '85%', maxWidth: 340, backgroundColor: '#262626', // matches webmail bg-secondary (dark mode) borderRightWidth: 1, borderRightColor: c.border, }, drawerSafe: { flex: 1 }, // Header header: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: c.border, }, headerClose: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm, }, account: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm, }, accountPressed: { backgroundColor: c.surfaceHover }, accountChevronOpen: { transform: [{ rotate: '180deg' }] }, accountAvatar: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', }, accountAvatarText: { ...typography.bodySemibold, color: c.primaryForeground, }, accountInfo: { flex: 1, minWidth: 0 }, accountName: { ...typography.bodyMedium, color: c.text }, accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 }, // Account menu - floating popover card under the header accountMenu: { marginHorizontal: spacing.sm, marginTop: spacing.xs, marginBottom: spacing.sm, backgroundColor: '#1f1f1f', borderRadius: radius.md, borderWidth: 1, borderColor: c.border, overflow: 'hidden', // Elevation / shadow shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 8, }, accountMenuSection: { paddingVertical: 4, }, accountMenuList: { maxHeight: 260, }, accountMenuRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingHorizontal: spacing.md, paddingVertical: 10, }, accountMenuRowActive: { backgroundColor: 'rgba(59,130,246,0.08)', }, accountMenuAvatarWrap: { position: 'relative', width: 36, height: 36, }, accountMenuAvatar: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', }, accountMenuAvatarText: { ...typography.caption, fontWeight: '600', color: c.primaryForeground, }, accountMenuCheckBadge: { position: 'absolute', right: -3, bottom: -3, width: 16, height: 16, borderRadius: 8, backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: '#1f1f1f', }, accountMenuInfo: { flex: 1, minWidth: 0, paddingTop: 1 }, accountMenuNameRow: { flexDirection: 'row', alignItems: 'center', gap: 5, }, accountMenuName: { ...typography.bodyMedium, color: c.text, flexShrink: 1, fontWeight: '600', }, accountMenuHost: { ...typography.caption, color: c.textMuted, marginTop: 2, }, accountMenuStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 4, }, accountMenuStatusDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#22c55e', }, accountMenuStatusDotOffline: { backgroundColor: c.textMuted, }, accountMenuStatusText: { fontSize: 10, lineHeight: 12, color: c.textMuted, flexShrink: 1, }, accountMenuDivider: { height: StyleSheet.hairlineWidth, backgroundColor: c.border, }, accountMenuAction: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: spacing.md, paddingVertical: 10, minHeight: 40, }, accountMenuActionPressed: { backgroundColor: c.surfaceHover }, accountMenuActionText: { ...typography.body, color: c.text, flexShrink: 1, fontSize: 13, }, // Scroll scroll: { flex: 1 }, scrollContent: { paddingBottom: spacing.md }, // Section header sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: spacing.md, paddingTop: spacing.md, paddingBottom: 4, }, sectionHeaderText: { ...typography.bodySemibold, color: c.text, }, sectionSettings: { padding: 4 }, empty: { ...typography.body, color: c.textMuted, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, // Row row: { flexDirection: 'row', alignItems: 'center', minHeight: 44, paddingRight: spacing.sm, borderLeftWidth: 2, borderLeftColor: 'transparent', }, rowPressed: { backgroundColor: c.surfaceHover }, rowSelected: { backgroundColor: c.accent, borderLeftColor: c.primary, }, rowIndent: { flexDirection: 'row', alignItems: 'center', flexShrink: 0, }, chevron: { width: CHEVRON_SLOT, height: CHEVRON_SLOT, alignItems: 'center', justifyContent: 'center', }, rowIcon: { width: 16, height: 16, alignItems: 'center', justifyContent: 'center', marginRight: spacing.sm, }, rowLabel: { flex: 1, ...typography.body, color: c.text, }, rowLabelSelected: { ...typography.bodySemibold, color: c.text, }, counts: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginLeft: spacing.sm, }, countUnread: { ...typography.caption, fontWeight: '600', color: c.text, }, countSep: { ...typography.caption, color: c.textMuted, opacity: 0.6, }, countTotal: { ...typography.caption, color: c.textMuted, }, }); }