import React, { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails, StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X, } from 'lucide-react-native'; import { SettingsSection, Select } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { ownMailboxes, mailboxSubtreeIds } from '../../lib/mailbox-tree'; import { localizeMailboxName } from '../../lib/mailbox-label'; import { useEmailStore } from '../../stores/email-store'; import { useLocaleStore } from '../../stores/locale-store'; import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email'; import { jmapClient } from '../../api/jmap-client'; import type { Mailbox } from '../../api/types'; const ROLE_ICON: Record = { inbox: Inbox, drafts: FileText, sent: Send, trash: Trash, junk: ShieldAlert, spam: ShieldAlert, archive: Archive, important: Flag, flagged: Star, all: Mails, memos: StickyNote, scheduled: Clock, snoozed: AlarmClock, shared: Users, }; // Special-use roles a user folder can be given (RFC 8621 §2 + the common // Stalwart extras). The server enforces uniqueness per account. const ASSIGNABLE_ROLES = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash', 'important', 'all', 'flagged', 'memos', 'scheduled', 'snoozed']; function getIcon(mb: Mailbox) { if (mb.role && ROLE_ICON[mb.role]) return ROLE_ICON[mb.role]; return Folder; } const NO_PARENT = '__root__'; const OWN_ACCOUNT = '__own__'; const NO_ROLE = '__none__'; type Editor = | { kind: 'create' } | { kind: 'edit'; mailbox: Mailbox }; /** "Parent / Child" path used to label folders in the pickers. */ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => string): string { const byId = new Map(all.map((m) => [m.id, m])); const parts = [localizeMailboxName(mb.role, mb.name, t)]; let node: Mailbox | undefined = mb; let guard = 0; while (node?.parentId && guard++ < 16) { node = byId.get(node.parentId); if (node) parts.unshift(localizeMailboxName(node.role, node.name, t)); } return parts.join(' / '); } export function FolderSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const allMailboxes = useEmailStore((s) => s.mailboxes); const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const [editor, setEditor] = useState(null); const [draftName, setDraftName] = useState(''); const [draftParent, setDraftParent] = useState(NO_PARENT); const [draftAccount, setDraftAccount] = useState(OWN_ACCOUNT); const [draftRole, setDraftRole] = useState(NO_ROLE); const [busyId, setBusyId] = useState(null); const [saving, setSaving] = useState(false); useEffect(() => { if (mailboxes.length === 0) void fetchMailboxes(); }, [mailboxes.length, fetchMailboxes]); // Shared/group accounts the user may create folders in (webmail: "New // folder" on a shared account header routes to the owner account). const sharedAccounts = React.useMemo(() => { const out: { id: string; name: string }[] = []; const seen = new Set(); for (const m of allMailboxes) { if (!m.isShared || !m.accountId || seen.has(m.accountId)) continue; if (m.myRights?.mayCreateChild === false) continue; seen.add(m.accountId); out.push({ id: m.accountId, name: m.accountName || jmapClient.getAccountName(m.accountId) || m.accountId }); } return out; }, [allMailboxes]); // Folders of the account the editor targets, for the parent picker. const parentCandidates = React.useMemo(() => { const pool = draftAccount === OWN_ACCOUNT ? mailboxes : allMailboxes.filter((m) => m.isShared && m.accountId === draftAccount); // Moving a folder under itself or one of its descendants is impossible. const excluded = editor?.kind === 'edit' ? new Set(mailboxSubtreeIds(pool, editor.mailbox.id)) : new Set(); return pool .filter((m) => !excluded.has(m.id) && m.myRights?.mayCreateChild !== false) .map((m) => ({ value: m.id, label: pathOf(pool, m, t) })) .sort((a, b) => a.label.localeCompare(b.label)); }, [draftAccount, mailboxes, allMailboxes, editor, t]); const sorted = [...mailboxes].sort((a, b) => { const ar = a.role ? 0 : 1; const br = b.role ? 0 : 1; if (ar !== br) return ar - br; return pathOf(mailboxes, a, t).localeCompare(pathOf(mailboxes, b, t)); }); const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0); const openCreate = () => { setEditor({ kind: 'create' }); setDraftName(''); setDraftParent(NO_PARENT); setDraftAccount(OWN_ACCOUNT); setDraftRole(NO_ROLE); }; const openEdit = (mailbox: Mailbox) => { setEditor({ kind: 'edit', mailbox }); setDraftName(mailbox.name); setDraftParent(mailbox.parentId ?? NO_PARENT); setDraftAccount(OWN_ACCOUNT); setDraftRole(mailbox.role ?? NO_ROLE); }; const closeEditor = () => setEditor(null); const saveDraft = async () => { const name = draftName.trim(); if (!name) { Alert.alert(t('settings.folders.name_required', 'Name required')); return; } setSaving(true); try { if (editor?.kind === 'create') { const accountId = draftAccount === OWN_ACCOUNT ? undefined : draftAccount; const parentId = draftParent === NO_PARENT ? null : draftParent; const raw = parentId ? allMailboxes.find((m) => m.id === parentId)?.originalId ?? parentId : null; await createMailbox( { name, parentId: raw, ...(draftRole !== NO_ROLE ? { role: draftRole } : {}) }, accountId, ); } else if (editor?.kind === 'edit') { const mb = editor.mailbox; const changes: { name?: string; parentId?: string | null; role?: string | null } = {}; if (name !== mb.name && !mb.role) changes.name = name; const nextParent = draftParent === NO_PARENT ? null : draftParent; if ((mb.parentId ?? null) !== nextParent) changes.parentId = nextParent; const nextRole = draftRole === NO_ROLE ? null : draftRole; if ((mb.role ?? null) !== nextRole) changes.role = nextRole; if (Object.keys(changes).length > 0) await updateMailbox(mb.id, changes); } closeEditor(); // A reparent moves the whole subtree: re-read the tree rather than // patching one node (#855). await fetchMailboxes(); } catch (err) { Alert.alert(t('settings.folders.save_failed', 'Save failed'), err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; const confirmDelete = (mailbox: Mailbox) => { if (mailbox.role) { Alert.alert(t('settings.folders.cannot_delete', 'Cannot delete'), t('settings.folders.system_folder_delete', 'System folders cannot be removed.')); return; } const name = mailbox.name; if ((mailbox.totalEmails ?? 0) > 0) { Alert.alert( t('settings.folders.not_empty_title', 'Folder not empty'), t('settings.folders.not_empty_message', `"${name}" contains ${mailbox.totalEmails} emails. Delete anyway?`, { name, count: mailbox.totalEmails }), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, true); } }, ], ); return; } Alert.alert( t('mailbox_context_menu.delete_confirm_title', 'Delete folder'), t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${name}"? This action cannot be undone.`, { name }), [ { text: t('common.cancel', 'Cancel'), style: 'cancel' }, { text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, false); } }, ], ); }; const performDelete = async (mailbox: Mailbox, removeEmails: boolean) => { setBusyId(mailbox.id); try { await deleteMailbox(mailbox.id, undefined, { onDestroyRemoveEmails: removeEmails }); await fetchMailboxes(); } catch (err) { Alert.alert(t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'), err instanceof Error ? err.message : String(err)); } finally { setBusyId(null); } }; const roleOptions = React.useMemo(() => { const takenByOthers = new Set( mailboxes .filter((m) => m.role && (editor?.kind !== 'edit' || m.id !== editor.mailbox.id)) .map((m) => m.role as string), ); return [ { value: NO_ROLE, label: t('settings.folders.role_none', 'No special role') }, ...ASSIGNABLE_ROLES .filter((r) => !takenByOthers.has(r)) .map((r) => ({ value: r, label: localizeMailboxName(r, r.charAt(0).toUpperCase() + r.slice(1), t) })), ]; }, [mailboxes, editor, t]); return ( {mailboxes.length === 0 ? ( ) : ( {sorted.map((mb) => { const Icon = getIcon(mb); const depth = pathOf(mailboxes, mb, t).split(' / ').length - 1; return ( openEdit(mb)} onLongPress={() => !mb.role && confirmDelete(mb)} style={({ pressed }) => [ styles.folderRow, pressed && styles.folderRowPressed, { paddingLeft: spacing.md + depth * 12 }, ]} > {localizeMailboxName(mb.role, mb.name, t)} {mb.role && ( {mb.role} )} {mb.unreadEmails > 0 && ( {mb.unreadEmails} )} {mb.totalEmails} {busyId === mb.id ? ( ) : ( )} ); })} )} {editor?.kind === 'edit' ? t('settings.folders.edit_folder', `Edit "${editor.mailbox.name}"`, { name: editor.mailbox.name }) : t('settings.folders.new_folder', 'New folder')} {t('settings.folders.folder_name', 'Folder name')} {editor?.kind === 'edit' && editor.mailbox.role ? ( {t('settings.folders.system_folder_rename', 'System folders cannot be renamed.')} ) : null} {editor?.kind === 'create' && sharedAccounts.length > 0 && ( <> {t('settings.folders.account', 'Account')} {draftAccount === OWN_ACCOUNT && ( <> {t('settings.folders.role', 'Special use')}