import React from 'react'; import { View, Text, StyleSheet, Pressable, ScrollView, Modal, TextInput, Alert, Animated, Dimensions, Easing, ActivityIndicator, } 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, Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag, CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw, type AppIcon, } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useAnimDuration } from '../theme/dynamic'; import { useEmailStore, spannedAccounts, trashAndJunkIds } from '../stores/email-store'; import { useAuthStore } from '../stores/auth-store'; import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; import { MAX_ACCOUNTS, accountRowText, serverHostLabel } from '../lib/account-utils'; import { toUnicodeEmail } from '../lib/idn'; import { buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode, } from '../lib/mailbox-tree'; import { localizeMailboxName } from '../lib/mailbox-label'; import { showUnifiedSection, visibleCrossViews } from '../lib/unified-section'; import { generateAvatarColor, getAccountInitials } from '../lib/avatar-utils'; import { jmapClient } from '../api/jmap-client'; import { markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, } from '../api/email'; import { useTagCountsStore } from '../stores/tag-counts-store'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; import type { Mailbox } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { confirmSignOut } from '../lib/sign-out-confirm'; import { SafeAreaModal } from './SafeAreaModal'; import { setPendingSettingsTab } from '../navigation/pending-settings-tab'; import { isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted, type NotificationMutes, } from '../lib/notification-mutes'; import { serverSupportsEmailPush } from '../lib/push-notifications'; import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon'; import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; const ROW_PX_BASE = 8; const STORAGE_KEYS = { expanded: 'sidebar:expandedMailboxes', foldersExpanded: 'sidebar:foldersExpanded', tagsExpanded: 'sidebar:tagsExpanded', unifiedExpanded: 'sidebar:unifiedExpanded', }; type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash'; const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'trash']; // Role folders that are kept out of the own tree while a virtual row stands // in for them (#495). const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']); // Distinct icons for the special-use roles (#288), else a plain folder: by // role only, never by name (lib/mailbox-icon.ts, #80). const ICONS: Record = { inbox: Inbox, sent: Send, drafts: FileIcon, trash: Trash2, junk: Ban, archive: Archive, important: Flag, flagged: Star, all: Mails, memos: StickyNote, scheduled: Clock, snoozed: AlarmClock, shared: Users, folder: Folder, 'folder-open': FolderOpen, }; function iconFor(role: string | null | undefined, hasChildren: boolean, isExpanded: boolean): AppIcon { return ICONS[mailboxIconKind(role, hasChildren, isExpanded)]; } const ROLE_COLOR_FIXED: Record = { inbox: '#60a5fa', sent: '#4ade80', drafts: '#a78bfa', junk: '#f87171', spam: '#f87171', archive: '#fbbf24', important: '#f97316', flagged: '#f59e0b', scheduled: '#38bdf8', snoozed: '#c084fc', memos: '#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, showTotal, onPressUnread }: { unread: number; total: number; showTotal: boolean; /** Tap the unread badge to open the folder filtered to unread. */ onPressUnread?: () => void; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); if (unread === 0 && (total === 0 || !showTotal)) return null; return ( {unread > 0 && ( {unread} )} {showTotal && unread > 0 && total > 0 && /} {showTotal && total > 0 && {total}} ); } interface SidebarRowProps { icon: React.ReactElement; label: string; depth: number; isSelected: boolean; /** Header for a shared/group account: styled as a section, never selected. */ isAccountHeader?: boolean; unread: number; total: number; showTotal: boolean; hasChildren: boolean; isExpanded: boolean; /** New mail here does not notify (#78). */ muted?: boolean; onPress: () => void; onLongPress?: () => void; onToggleExpand: () => void; onPressUnread?: () => void; } function SidebarRow({ icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal, hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread, }: SidebarRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const leftPad = ROW_PX_BASE + depth * INDENT_STEP; // Which folder is open, and the unread count with the folder's name // ("Inbox, 3 unread"), for screen readers (webmail 7c282f1f4). const accessibleName = unread > 0 && !isAccountHeader ? t('shell.name_unread', '{name}, {count} unread', { name: label, count: unread }) : label; return ( [ styles.row, isSelected && styles.rowSelected, pressed && !isSelected && styles.rowPressed, ]} > {hasChildren ? ( {isExpanded ? ( ) : ( )} ) : ( )} {icon} {label} {muted && ( )} ); } // A long-press action sheet for a folder (the webmail's folder context menu). interface SheetAction { key: string; label: string; icon: AppIcon; destructive?: boolean; onPress: () => void; } function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); // The Modal reaches under a tablet's taskbar: keep the last action above it. const bottomInset = useSheetBottomInset(); return ( {title} {actions.map((a) => { const Icon = a.icon; return ( { onClose(); a.onPress(); }} style={({ pressed }) => [styles.sheetRow, pressed && styles.rowPressed]} > {a.label} ); })} ); } // Cross-platform text prompt (Alert.prompt is iOS-only). function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }: { title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (value: string) => void; onClose: () => void; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const [value, setValue] = React.useState(initial ?? ''); // Centred above the keyboard (the field autofocuses) and the taskbar. const bottomInset = useSheetBottomInset(); const submit = () => { const name = value.trim(); if (!name) return; onClose(); onSubmit(name); }; return ( {title} {message ? {message} : null} {t('common.cancel', 'Cancel')} {confirmLabel} ); } interface SidebarDrawerProps { visible: boolean; onClose: () => void; /** Render as a permanent sidebar (wide screens) instead of a modal drawer. */ docked?: boolean; } export default function SidebarDrawer({ visible, onClose, docked = false }: SidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const mailboxes = useEmailStore((s) => s.mailboxes); const accountSnapshots = useEmailStore((s) => s.accountSnapshots); const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const filters = useEmailStore((s) => s.filters); const selectMailbox = useEmailStore((s) => s.selectMailbox); const setFilters = useEmailStore((s) => s.setFilters); const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const refreshEmails = useEmailStore((s) => s.refreshEmails); 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 requestReauth = useAuthStore((s) => s.requestReauth); const activeAccountId = useAuthStore((s) => s.activeAccountId); const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount); const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView); const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView); const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView); const notificationMutes = useSettingsStore((s) => s.notificationMutes); const updateSetting = useSettingsStore((s) => s.updateSetting); const keywordDefs = useKeywordsStore((s) => s.keywords); const keywordsHydrated = useKeywordsStore((s) => s.hydrated); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); const navigation = useNavigation>(); const [foldersExpanded, setFoldersExpanded] = React.useState(true); const [tagsExpanded, setTagsExpanded] = React.useState(true); const [unifiedExpanded, setUnifiedExpanded] = React.useState(false); const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set()); const [accountMenuOpen, setAccountMenuOpen] = React.useState(false); const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null); const [prompt, setPrompt] = React.useState<{ title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void; } | null>(null); const [busy, setBusy] = React.useState(false); const tagCounts = useTagCountsStore((s) => s.counts); const tagCountsGeneration = useTagCountsStore((s) => s.generation); const ensureTagCounts = useTagCountsStore((s) => s.ensure); React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]); React.useEffect(() => { void (async () => { try { const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.expanded), AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded), AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded), AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded), ]); 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'); if (rawTags != null) setTagsExpanded(rawTags === 'true'); if (rawUnified != null) setUnifiedExpanded(rawUnified === 'true'); } catch { /* ignore */ } })(); }, [mailboxes]); // The server's Scheduled folder is hidden while the virtual row stands in // for it (#495) — only when the server can actually schedule sends. const hideOwnRoles = React.useMemo( () => (jmapClient.hasDelayedSend() ? HIDDEN_WITH_VIRTUAL_ROW : undefined), // eslint-disable-next-line react-hooks/exhaustive-deps [mailboxes], ); const tree = React.useMemo(() => buildMailboxTree(mailboxes, { hideOwnRoles }), [mailboxes, hideOwnRoles]); 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 toggleSection = (key: keyof typeof STORAGE_KEYS, setter: React.Dispatch>) => { setter((prev) => { const next = !prev; void AsyncStorage.setItem(STORAGE_KEYS[key], String(next)).catch(() => {}); return next; }); }; const handleSelect = React.useCallback((id: string) => { // A tag view was open: leave it so the folder shows its own mail. if (filters.keyword) clearSearchAndFilters(); void selectMailbox(id); onClose(); }, [selectMailbox, onClose, filters.keyword, clearSearchAndFilters]); // Tap the unread count → the folder filtered to unread (webmail sidebar). const handleSelectUnread = React.useCallback((id: string) => { void selectMailbox(id).then(() => setFilters({ isUnread: true })); onClose(); }, [selectMailbox, setFilters, onClose]); // Tag view (#175): every folder, messages carrying the tag. const selectTag = React.useCallback((id: string) => { setFilters({ keyword: keywordToken(id) }); onClose(); }, [setFilters, onClose]); // Tag counts: one query pair per tag and account. A tag view lists the // tagged mail of the own and the shared accounts, so its badge counts all // of them (#1038). They are cached until an Email change is reported // (PF6): opening the drawer costs no request when nothing changed, and // while it is open a change refreshes them. const tagAccountsKey = React.useMemo(() => spannedAccounts(mailboxes).join('|'), [mailboxes]); React.useEffect(() => { if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return; const accounts = tagAccountsKey.split('|').map((id) => id || undefined); // Trash and Junk stay out of the counts as they do of the tag view (#1156). const excluded = Object.fromEntries(accounts.map((id) => [id ?? '', trashAndJunkIds(mailboxes, id)])); void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded); }, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]); // ── Folder actions (long-press) ─────────────────────────────────────── const refFor = (mb: Mailbox) => ({ id: mb.originalId ?? mb.id, accountId: mb.isShared ? mb.accountId : undefined, }); // Notification mutes (#78). Muting only takes effect through the server's // emailPush filter, so the actions show only where that exists. The // settings are keyed by the server's ids: a folder of the own account may // carry no accountId, so the client's stands in. let primaryJmapAccountId: string | null = null; try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; } const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush(); const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || ''; const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next); const muteActionsForAccount = (accountId: string): SheetAction[] => { const muted = isAccountMuted(notificationMutes, accountId); const overrides = mailboxOverridesForAccount(notificationMutes, accountId); const actions: SheetAction[] = [{ key: 'mute', label: muted ? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account') : t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'), icon: muted ? Bell : BellOff, onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)), }]; if (overrides.muted.length + overrides.unmuted.length > 0) { actions.push({ key: 'reset-mutes', label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'), icon: RotateCcw, onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)), }); } return actions; }; const openOwnAccountSheet = () => { if (!canMute) return; setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) }); }; const runFolderAction = async (label: string, work: () => Promise, affectsCurrent: boolean) => { setBusy(true); try { await work(); } catch (err) { Alert.alert(label, err instanceof Error ? err.message : String(err)); } finally { // Also after a refusal: what did get written shows. await fetchMailboxes().catch(() => undefined); if (affectsCurrent) await refreshEmails().catch(() => undefined); setBusy(false); } }; const markRead = (ids: string[]) => { const targets = ids .map((id) => mailboxes.find((m) => m.id === id)) .filter((m): m is Mailbox => !!m && m.unreadEmails > 0); const affectsCurrent = !!currentMailboxId && ids.includes(currentMailboxId); void runFolderAction( t('mailbox_context_menu.toast_error_mark_read', 'Failed to mark as read'), // Past a folder the server refuses, reporting the refusal at the end. () => markMailboxesAsRead(targets.map(refFor)), affectsCurrent, ); }; const openFolderSheet = (node: MailboxNode) => { const mb = mailboxes.find((m) => m.id === node.id); if (!mb) return; const name = localizeMailboxName(mb.role, mb.name, t); const ref = refFor(mb); const subtree = mailboxSubtreeIds(mailboxes, mb.id); const actions: SheetAction[] = [ { key: 'read', label: t('mailbox_context_menu.mark_folder_read', 'Mark folder as read'), icon: CheckCheck, onPress: () => markRead([mb.id]) }, ]; if (subtree.length > 1) { actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) }); } if (canMute) { const accountId = muteAccountOf(mb.accountId); const muted = isMailboxMuted(notificationMutes, accountId, ref.id); actions.push({ key: 'mute', label: muted ? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications') : t('mailbox_context_menu.mute_notifications', 'Mute notifications'), icon: muted ? Bell : BellOff, onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)), }); } if (!mb.isShared) { actions.push({ key: 'all', label: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'), icon: CheckCheck, onPress: () => Alert.alert( t('mailbox_context_menu.mark_all_confirm_title', 'Mark all folders as read'), t('mailbox_context_menu.mark_all_confirm_message', 'Mark every unread message in your personal account as read?'), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'), onPress: () => markRead(ownMailboxes(mailboxes).map((m) => m.id)) }, ], ), }); } if (mb.role === 'trash' || mb.role === 'junk' || mb.role === 'spam') { actions.push({ key: 'empty', label: t('mailbox_context_menu.empty_folder', 'Empty folder'), icon: Eraser, destructive: true, onPress: () => Alert.alert( t('email_list.empty_folder.confirm_title', 'Empty folder'), t('email_list.empty_folder.confirm_message', 'All emails in this folder will be permanently deleted. This action cannot be undone.'), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('email_list.empty_folder.confirm_button', 'Empty folder'), style: 'destructive', onPress: () => void runFolderAction( t('mailbox_context_menu.toast_error_empty', 'Failed to empty folder'), () => emptyMailbox(ref.id, ref.accountId), currentMailboxId === mb.id, ), }, ], ), }); } if (mb.myRights?.mayCreateChild !== false) { actions.push({ key: 'sub', label: t('mailbox_context_menu.new_subfolder', 'New subfolder...'), icon: FolderPlus, onPress: () => setPrompt({ title: t('mailbox_context_menu.new_subfolder', 'New subfolder...'), message: t('mailbox_context_menu.prompt_new_subfolder', 'Enter a name for the new subfolder.'), confirmLabel: t('mailbox_context_menu.create', 'Create'), onSubmit: (value) => void runFolderAction( t('mailbox_context_menu.toast_error_create', 'Failed to create folder'), async () => { await createMailbox({ name: value, parentId: ref.id }, ref.accountId); setExpandedFolders((prev) => { const next = new Set(prev); next.add(mb.id); void persistExpanded(next); return next; }); }, false, ), }), }); } if (!mb.role && mb.myRights?.mayRename !== false) { actions.push({ key: 'rename', label: t('mailbox_context_menu.rename', 'Rename...'), icon: Pencil, onPress: () => setPrompt({ title: t('mailbox_context_menu.rename', 'Rename...'), message: t('mailbox_context_menu.prompt_rename', 'Enter a new name for this folder.'), initial: mb.name, confirmLabel: t('mailbox_context_menu.rename_confirm', 'Rename'), onSubmit: (value) => void runFolderAction( t('mailbox_context_menu.toast_error_rename', 'Failed to rename folder'), () => updateMailbox(ref.id, { name: value }, ref.accountId), false, ), }), }); } if (!mb.role && mb.myRights?.mayDelete !== false) { actions.push({ key: 'delete', label: t('mailbox_context_menu.delete_folder', 'Delete folder'), icon: Trash2, destructive: true, onPress: () => Alert.alert( t('mailbox_context_menu.delete_confirm_title', 'Delete folder'), t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${mb.name}"? This action cannot be undone.`, { name: mb.name }), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => void runFolderAction( t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'), async () => { await deleteMailbox(ref.id, ref.accountId, { onDestroyRemoveEmails: mb.totalEmails > 0 }); if (currentMailboxId === mb.id) { const inbox = ownMailboxes(mailboxes).find((m) => m.role === 'inbox'); if (inbox) void selectMailbox(inbox.id); } }, false, ), }, ], ), }); } setSheet({ title: name, actions }); }; const openSharedAccountSheet = (node: MailboxNode) => { const accountId = node.accountId; if (!accountId) return; const canCreate = node.children.some((n) => n.myRights?.mayCreateChild !== false); const actions: SheetAction[] = [ { key: 'read', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)), }, ]; if (canMute) actions.push(...muteActionsForAccount(accountId)); if (canCreate) { actions.push({ key: 'new', label: t('mailbox_context_menu.new_folder', 'New folder...'), icon: FolderPlus, onPress: () => setPrompt({ title: t('mailbox_context_menu.new_folder', 'New folder...'), message: t('mailbox_context_menu.prompt_new_folder', 'Enter a name for the new folder.'), confirmLabel: t('mailbox_context_menu.create', 'Create'), onSubmit: (value) => void runFolderAction( t('mailbox_context_menu.toast_error_create', 'Failed to create folder'), () => createMailbox({ name: value }, accountId), false, ), }), }); } setSheet({ title: node.name, actions }); }; // ── Unified rows ────────────────────────────────────────────────────── // Shown only when the views hold more than this account's own folders: // cross-account with several accounts, or group inboxes to merge (#843). // Counts are projected from the mailbox lists we hold: the active // account's (own + shared) plus, cross-account, the tucked-away snapshots // of the other accounts. const hasSharedInbox = mailboxes.some((m) => m.isShared && m.role === 'inbox'); const showUnified = showUnifiedSection({ accountCount: accounts.length, unifiedCrossAccount, includeGroupInUnified, hasSharedInbox, }); const crossViews = visibleCrossViews({ showUnified, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, }); const unifiedCounts = React.useMemo(() => { const pools: Mailbox[][] = [mailboxes]; if (unifiedCrossAccount) { for (const [id, snap] of Object.entries(accountSnapshots)) { if (id !== activeAccountId) pools.push(snap.mailboxes); } } const roleCount = (role: string): { unread: number; total: number; present: boolean } => { let unread = 0; let total = 0; let present = false; for (const pool of pools) { for (const m of pool) { if (m.isShared && !includeGroupInUnified) continue; if (m.role === role || (role === 'junk' && m.role === 'spam')) { present = true; unread += m.unreadEmails ?? 0; total += m.totalEmails ?? 0; } } } return { unread, total, present }; }; let crossUnread = 0; for (const pool of pools) { for (const m of pool) { if (m.isShared && !includeGroupInUnified) continue; if (!['junk', 'spam', 'sent', 'archive', 'trash', 'drafts'].includes(m.role ?? '')) crossUnread += m.unreadEmails ?? 0; } } return { inbox: roleCount('inbox'), roles: UNIFIED_ROLES.map((role) => ({ role, ...roleCount(role) })).filter((r) => r.present), crossUnread, }; }, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]); const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, false, false); const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); // Runs the slide-in. Kicked from both the visible effect and the Modal's // onShow: on the very first open the modal's native view is not attached yet // when the effect fires, so that first animation is dropped and the drawer // stays parked off-screen. Re-running it once the modal is on screen commits // the final offset (a no-op on every subsequent open). const runOpen = React.useCallback(() => { Animated.parallel([ Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), ]).start(); }, [slideX, overlayOpacity, openDuration]); React.useEffect(() => { if (visible) { runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }), ]).start(); } }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); 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]); // The header names the server with its port when that isn't the default: // two servers on one host are two accounts with the same address. const hostLabel = React.useMemo(() => (serverUrl ? serverHostLabel(serverUrl) : ''), [serverUrl]); const accountEmailFull = React.useMemo(() => { if (!username) return hostname; if (username.includes('@')) return username; return hostname ? `${username}@${hostname}` : username; }, [username, hostname]); const confirmRemoveAccount = (acc: { id: string; email: string; username: string }) => { const label = acc.email || acc.username; Alert.alert( t('sidebar.remove_account', 'Remove account'), t('sidebar.remove_account_confirm', `Remove ${label} from this device? You can add it back later.`, { account: label }), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('sidebar.remove_account', 'Remove account'), style: 'destructive', onPress: () => { void useAuthStore.getState().removeAccount(acc.id); }, }, ], ); }; const tagViewActive = !!filters.keyword; const panel = ( {/* Header: close + account switcher */} {!docked && ( )} setAccountMenuOpen((v) => !v)} style={({ pressed }) => [styles.account, pressed && styles.accountPressed]} > {initials} {username || t('settings.account.title', 'Account')} {hostLabel ? ( {hostLabel} ) : null} {busy ? ( ) : ( )} {accountMenuOpen && ( {accounts.map((acc) => { const isActive = acc.id === activeAccountId; // With the port when it isn't the default (see hostLabel). const accHost = serverHostLabel(acc.serverUrl); // The address leads when another account has the same name // (webmail 3bb9f7df8). const rowText = accountRowText(acc, accounts); return ( { // The server refused its stored sign-in: sign in again. if (acc.needsSignIn) { setAccountMenuOpen(false); onClose(); void requestReauth(acc.id); return; } if (isActive) return; setAccountMenuOpen(false); onClose(); void switchAccount(acc.id); }} onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)} delayLongPress={350} accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')} style={({ pressed }) => [ styles.accountMenuRow, isActive && styles.accountMenuRowActive, pressed && !isActive && styles.accountMenuActionPressed, ]} > {getAccountInitials(acc.displayName, acc.email || acc.username)} {isActive && ( )} {toUnicodeEmail(rowText.title)} {acc.isDefault && ( )} {rowText.subtitle ? ( {toUnicodeEmail(rowText.subtitle)} ) : null} {acc.hasError ? ( <> {acc.needsSignIn ? t('sidebar.account_sign_in_again', 'Sign in again') : acc.errorMessage || t('sidebar.account_error', 'Needs attention')} ) : ( <> {/* The connection dot only means something for the account the live session serves. */} {isActive && ( )} {accHost} )} {!isActive && ( confirmRemoveAccount(acc)} hitSlop={8} style={styles.accountMenuRemove} accessibilityRole="button" accessibilityLabel={t('sidebar.remove_account', 'Remove account')} > )} ); })} {accounts.length < MAX_ACCOUNTS && ( <> [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); onClose(); navigation.navigate('AddAccount'); }} > {t('sidebar.add_account', '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)} > {t('sidebar.set_as_default', 'Set as default')} ); })()} [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); confirmSignOut(t, { account: accountEmailFull || undefined }, () => { onClose(); void logout(); }); }} > {t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })} {accounts.length > 1 && ( [ styles.accountMenuAction, pressed && styles.accountMenuActionPressed, ]} onPress={() => { setAccountMenuOpen(false); confirmSignOut(t, { all: true }, () => { onClose(); void logoutAll(); }); }} > {t('sidebar.sign_out_all', 'Sign out of all accounts')} )} )} {/* Quick views */} {showUnified && ( [styles.quickRow, pressed && styles.rowPressed]} onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }} > {unifiedCrossAccount && accounts.length > 1 ? t('sidebar.all_accounts', 'All accounts') : t('sidebar.unified_inbox', 'All inboxes')} )} [styles.quickRow, pressed && styles.rowPressed]} onPress={() => { onClose(); navigation.navigate('Scheduled'); }} > {t('sidebar.scheduled', 'Scheduled')} {/* Unified per-role and cross views */} {(showUnified || crossViews.length > 0) && ( <> toggleSection('unifiedExpanded', setUnifiedExpanded)}> {unifiedExpanded ? ( ) : ( )} {t('sidebar.unified_mailbox', 'Unified mailbox')} {unifiedExpanded && ( <> {([ { view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread }, { view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 }, { view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread }, ]).filter((row) => crossViews.includes(row.view)).map((row) => ( } label={row.label} depth={0} isSelected={false} unread={row.unread} total={0} showTotal={false} hasChildren={false} isExpanded={false} onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }} onToggleExpand={() => {}} /> ))} {showUnified && unifiedCounts.roles.map((r) => { const Icon = unifiedIcon(r.role); return ( } label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)} depth={0} isSelected={false} unread={r.unread} total={r.total} showTotal={showFolderTotalCount} hasChildren={false} isExpanded={false} onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }} onToggleExpand={() => {}} /> ); })} )} )} {/* Folders section */} toggleSection('foldersExpanded', setFoldersExpanded)} // The own account's notification mutes (#78), like the webmail's // menu on this header. onLongPress={canMute ? openOwnAccountSheet : undefined} delayLongPress={350} > {foldersExpanded ? ( ) : ( )} {t('sidebar.folders', 'Folders')} { onClose(); setPendingSettingsTab('folders'); navigation.navigate('MainTabs', { screen: 'Settings' } as never); }} accessibilityRole="button" accessibilityLabel={t('settings.tabs.folders', 'Folders')} > {foldersExpanded && ( mailboxes.length === 0 ? ( {t('sidebar.loading_mailboxes', 'Loading mailboxes...')} ) : ( visibleNodes.map((node) => { const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); // A shared/group account's header has no mailbox behind it — // tapping it only opens or closes that account's folders. const Icon = node.isAccountNode ? Users : iconFor(node.role, hasChildren, isExpanded); const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; return ( } label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)} depth={node.depth} isSelected={isSelected} isAccountHeader={node.isAccountNode} unread={node.unreadEmails} total={node.totalEmails} showTotal={showFolderTotalCount && !node.isAccountNode} muted={canMute && (node.isAccountNode ? isAccountMuted(notificationMutes, muteAccountOf(node.accountId)) : isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))} hasChildren={hasChildren} isExpanded={isExpanded} onPress={ node.isAccountNode ? () => toggleExpand(node.id) : () => handleSelect(node.id) } onLongPress={ node.isAccountNode ? () => openSharedAccountSheet(node) : () => openFolderSheet(node) } onToggleExpand={() => toggleExpand(node.id)} onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)} /> ); }) ) )} {/* Tags section (#175) */} {keywordDefs.length > 0 && ( <> toggleSection('tagsExpanded', setTagsExpanded)}> {tagsExpanded ? ( ) : ( )} {t('sidebar.tags', 'Tags')} {tagsExpanded && keywordDefs.map((kw) => { const counts = tagCounts[kw.id]; const isSelected = filters.keyword === keywordToken(kw.id); const dot = c.tags[kw.color]?.dot ?? c.textMuted; return ( } label={kw.label} depth={0} isSelected={isSelected} unread={counts?.unread ?? 0} total={counts?.total ?? 0} showTotal={showFolderTotalCount} hasChildren={false} isExpanded={false} onPress={() => selectTag(kw.id)} onToggleExpand={() => {}} /> ); })} )} ); // On a wide screen the drawer is a permanent sidebar next to the list. if (docked) { return ( {panel} {sheet && setSheet(null)} />} {prompt && ( setPrompt(null)} /> )} ); } // Its own safe-area provider: the panel's SafeAreaView starts off screen // (it slides in), and without one it measured no insets on iOS, so the // account header sat under the status bar. return ( {panel} {sheet && setSheet(null)} />} {prompt && ( setPrompt(null)} /> )} ); } /** Width of the permanent sidebar (the webmail's default sidebar is 256 px). */ export const DOCKED_WIDTH = 272; 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, // Mirror webmail's bg-secondary in both palettes - was hardcoded to the // dark-mode value, which left light mode unreadable (dark bg + dark text). backgroundColor: c.secondary, borderRightWidth: 1, borderRightColor: c.border, }, drawerSafe: { flex: 1 }, docked: { width: DOCKED_WIDTH, backgroundColor: c.secondary, borderRightWidth: 1, borderRightColor: c.border, }, // 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: c.popover, 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, }, 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, }, accountMenuRemove: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm, }, 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: 10 }, quickRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minHeight: 44, paddingHorizontal: spacing.md, borderLeftWidth: 2, borderLeftColor: 'transparent', }, quickRowLabel: { ...typography.body, color: c.text }, 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, }, rowMuted: { marginLeft: spacing.xs }, rowLabelAccount: { fontSize: 13, lineHeight: 18, fontWeight: '500', color: c.textSecondary, }, 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, }, // Long-press action sheet / name prompt sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', }, sheet: { position: 'absolute', left: 0, right: 0, bottom: 0, backgroundColor: c.popover, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg, borderTopWidth: 1, borderColor: c.border, paddingTop: spacing.sm, paddingBottom: spacing.xl, }, sheetTitle: { ...typography.bodySemibold, color: c.text, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, sheetRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingHorizontal: spacing.lg, minHeight: 44, }, sheetRowText: { ...typography.body, color: c.text }, // Centres the prompt in the space above the keyboard and the navigation bar. promptCenter: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', paddingHorizontal: spacing.lg, }, promptCard: { backgroundColor: c.popover, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, paddingVertical: spacing.sm, }, promptMessage: { ...typography.caption, color: c.textSecondary, paddingHorizontal: spacing.lg }, promptInput: { ...typography.body, color: c.text, marginHorizontal: spacing.lg, marginTop: spacing.sm, backgroundColor: c.surface, borderWidth: 1, borderColor: c.border, borderRadius: radius.sm, paddingHorizontal: spacing.md, paddingVertical: 8, }, promptActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm, paddingHorizontal: spacing.md, paddingTop: spacing.sm, }, promptButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm }, promptButtonText: { ...typography.bodyMedium, color: c.text }, }); }