"use client"; import { useState, useRef, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { useEmailStore } from '@/stores/email-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; import { usePolicyStore } from '@/stores/policy-store'; import { toast } from '@/stores/toast-store'; import { SettingsSection, SettingItem, Select } from './settings-section'; import { Plus, Pencil, Trash2, Check, X, FolderPlus, Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square, type AppIcon, } from '@/components/icons'; import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils'; import { flattenVisibleTree, removeSubtree, getProjection, getDropPlan, type FlatFolder, } from '@/lib/folder-tree-dnd'; import { ChevronRight, ChevronDown, GripVertical } from '@/components/icons'; import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, MeasuringStrategy, type DragEndEvent, type DragMoveEvent, type DragOverEvent, type DragStartEvent, } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; // Must match the per-level indentation used in the row styles below: dragging // a folder this many pixels right/left targets one nesting level deeper/higher. const INDENTATION_WIDTH = 16; const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const; // "Move to" value for the root of the folder tree. const TOP_LEVEL = '__top__'; const ROLE_ICONS: Record = { inbox: Inbox, drafts: FileText, sent: Send, trash: Trash, junk: ShieldAlert, archive: Archive, shared: Users, important: AlertTriangle, memos: NotebookPen, scheduled: CalendarClock, snoozed: BellOff, }; const ICON_CHOICES: { name: string; icon: AppIcon }[] = [ { name: 'Folder', icon: Folder }, { name: 'Star', icon: Star }, { name: 'Heart', icon: Heart }, { name: 'Bookmark', icon: Bookmark }, { name: 'Tag', icon: Tag }, { name: 'Flag', icon: Flag }, { name: 'Briefcase', icon: Briefcase }, { name: 'Users', icon: Users }, { name: 'Bell', icon: Bell }, { name: 'Zap', icon: Zap }, { name: 'Globe', icon: Globe }, { name: 'Lock', icon: Lock }, { name: 'Eye', icon: Eye }, { name: 'MessageSquare', icon: MessageSquare }, { name: 'Mail', icon: Mail }, { name: 'Inbox', icon: Inbox }, { name: 'Archive', icon: Archive }, { name: 'FileText', icon: FileText }, ]; function IconPicker({ currentIcon, onSelect, onClose }: { currentIcon: string; onSelect: (iconName: string) => void; onClose: () => void; }) { const ref = useRef(null); useEffect(() => { const handleClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleKey); return () => { document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleKey); }; }, [onClose]); return (
{ICON_CHOICES.map(({ name, icon: Icon }) => ( ))}
); } /** * Wraps a folder row with a drag handle so it can be reordered within its * sibling group. The handle carries the dnd-kit listeners; the rest of the row * (buttons, inline editors) stays fully interactive. */ function SortableFolderRow({ id, title, children }: { id: string; title: string; children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : undefined, position: isDragging ? 'relative' : undefined, }; return (
{children}
); } export function FolderSettings() { const t = useTranslations('settings.folders'); const { client } = useAuthStore(); const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox, moveMailboxes } = useEmailStore(); const sensors = useSensors( // Small activation distance so clicking the row's buttons still works. useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const { folderIcons, setFolderIcon } = useSettingsStore(); const { isFeatureEnabled } = usePolicyStore(); const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled'); useEffect(() => { if (client && mailboxes.length === 0) { fetchMailboxes(client); } }, [client, mailboxes.length, fetchMailboxes]); const [isCreating, setIsCreating] = useState(false); const [creatingParentId, setCreatingParentId] = useState(null); const [newFolderName, setNewFolderName] = useState(''); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); const [deletingId, setDeletingId] = useState(null); const [iconPickerId, setIconPickerId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [expandedFolders, setExpandedFolders] = useState>(new Set()); // Drag state: the tree renders as one flat sortable list; vertical movement // picks the insertion point, horizontal movement the nesting depth. A drag // can therefore both reorder folders and move them under a different parent // (GitHub #855). const [activeDragId, setActiveDragId] = useState(null); const [overId, setOverId] = useState(null); const [offsetLeft, setOffsetLeft] = useState(0); // Folders ticked for a bulk move (#1173). Shift-click ticks or unticks the // visible rows between the last clicked checkbox and this one. const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectionAnchor, setSelectionAnchor] = useState(null); const [moveTarget, setMoveTarget] = useState(''); const [isMoving, setIsMoving] = useState(false); const ownMailboxes = mailboxes.filter(mb => !mb.isShared); const folderTree = buildMailboxTree(ownMailboxes); const visibleItems = flattenVisibleTree(folderTree, expandedFolders); // While dragging, the folder's subtree travels with it: hide it from the // list so it can't become its own drop target. const dragItems = activeDragId ? removeSubtree(visibleItems, activeDragId) : visibleItems; const projected = activeDragId && overId ? getProjection(dragItems, activeDragId, overId, offsetLeft, INDENTATION_WIDTH) : null; const findNode = (nodes: MailboxNode[], id: string): MailboxNode | undefined => { for (const n of nodes) { if (n.id === id) return n; const found = findNode(n.children, id); if (found) return found; } return undefined; }; const resetDragState = () => { setActiveDragId(null); setOverId(null); setOffsetLeft(0); }; const handleFolderDragStart = ({ active }: DragStartEvent) => { setActiveDragId(String(active.id)); setOverId(String(active.id)); }; const handleFolderDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x); const handleFolderDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null); const handleFolderDragEnd = (event: DragEndEvent) => { const { active, over, delta } = event; resetDragState(); if (!over || !client) return; const activeId = String(active.id); const items = removeSubtree(flattenVisibleTree(folderTree, expandedFolders), activeId); const projection = getProjection(items, activeId, String(over.id), delta.x, INDENTATION_WIDTH); if (!projection) return; const plan = getDropPlan(items, activeId, String(over.id), projection); if (!plan) return; if (!plan.parentChanged) { if (active.id === over.id) return; // dropped back where it started reorderMailboxes(client, plan.orderedSiblingIds).catch(() => { toast.error(t('reorder_error')); }); return; } const newParent = plan.parentId ? findNode(folderTree, plan.parentId) : null; if (plan.parentId && !newParent) return; if (newParent?.myRights && !newParent.myRights.mayCreateChild) { toast.error(t('move_error')); return; } // If the new parent is collapsed we can't see its children's positions, so // only reparent (no sibling reorder) and expand it to show the result. const siblingsVisible = !newParent || newParent.children.length === 0 || expandedFolders.has(newParent.id); if (newParent) { setExpandedFolders(prev => new Set(prev).add(newParent.id)); } moveMailbox(client, activeId, plan.parentId, siblingsVisible ? plan.orderedSiblingIds : undefined).catch(() => { toast.error(t('move_error')); }); }; const selectedCount = ownMailboxes.filter(mb => selectedIds.has(mb.id)).length; // A ticked folder inside another ticked folder travels with it, so only the // topmost ticked folders get a new parent; the rest keep their place. const moveRoots: MailboxNode[] = []; const collectMoveRoots = (nodes: MailboxNode[]) => { for (const n of nodes) { if (selectedIds.has(n.id)) moveRoots.push(n); else collectMoveRoots(n.children); } }; collectMoveRoots(folderTree); // A folder can't move into itself or below itself. const blockedTargets = new Set(); const blockSubtree = (n: MailboxNode) => { blockedTargets.add(n.id); n.children.forEach(blockSubtree); }; moveRoots.forEach(blockSubtree); const getFolderPath = (mb: { parentId?: string; name: string }): string => { const parts = [mb.name]; const seen = new Set(); let parentId = mb.parentId; while (parentId && !seen.has(parentId)) { seen.add(parentId); const parent = ownMailboxes.find(p => p.id === parentId); if (!parent) break; parts.unshift(parent.name); parentId = parent.parentId; } return parts.join(' / '); }; const moveTargetOptions = flattenMailboxTree(folderTree) .filter(mb => !blockedTargets.has(mb.id) && !(mb.myRights && !mb.myRights.mayCreateChild)) .map(mb => ({ value: mb.id, label: getFolderPath(mb) })); const handleSelectClick = (id: string, e: React.MouseEvent) => { const tick = !selectedIds.has(id); let ids = [id]; if (e.shiftKey && selectionAnchor) { const order = visibleItems.filter(i => i.node.myRights?.mayRename).map(i => i.id); const from = order.indexOf(selectionAnchor); const to = order.indexOf(id); if (from >= 0 && to >= 0) ids = order.slice(Math.min(from, to), Math.max(from, to) + 1); } setSelectedIds(prev => { const next = new Set(prev); for (const x of ids) { if (tick) next.add(x); else next.delete(x); } return next; }); setSelectionAnchor(id); }; const clearSelection = () => { setSelectedIds(new Set()); setSelectionAnchor(null); setMoveTarget(''); }; const handleMoveSelected = async () => { if (!client || !moveTarget) return; const parentId = moveTarget === TOP_LEVEL ? null : moveTarget; const ids = moveRoots.filter(n => (n.parentId ?? null) !== parentId).map(n => n.id); if (ids.length === 0) { clearSelection(); return; } setIsMoving(true); try { const failed = await moveMailboxes(client, ids, parentId); if (parentId) setExpandedFolders(prev => new Set(prev).add(parentId)); if (failed.length === 0) { toast.success(t('folders_moved', { count: ids.length })); clearSelection(); } else { toast.error(t('move_partial_error', { count: failed.length })); // Leave the refused folders ticked so they are easy to find. setSelectedIds(new Set(failed)); } } catch { toast.error(t('move_error')); } finally { setIsMoving(false); } }; const getRoleMailboxId = (role: string): string => { const mb = ownMailboxes.find(m => m.role === role); return mb?.id ?? ''; }; const getIconForMailbox = (mb: { id: string; role?: string }): AppIcon => { // Custom icon takes priority for non-role folders const customIconName = folderIcons[mb.id]; if (customIconName) { const found = ICON_CHOICES.find(c => c.name === customIconName); if (found) return found.icon; } // Role folders get their role icon if (mb.role && ROLE_ICONS[mb.role]) return ROLE_ICONS[mb.role]; return Folder; }; const getIconName = (mb: { id: string; role?: string }): string => { if (folderIcons[mb.id]) return folderIcons[mb.id]; if (mb.role && ROLE_ICONS[mb.role]) { const entry = Object.entries(ROLE_ICONS).find(([r]) => r === mb.role); if (entry) { const found = ICON_CHOICES.find(c => c.icon === entry[1]); if (found) return found.name; } } return 'Folder'; }; const toggleExpanded = (id: string) => { setExpandedFolders(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const startCreateSubfolder = (parentId: string) => { setCreatingParentId(parentId); setIsCreating(true); setNewFolderName(''); setExpandedFolders(prev => new Set(prev).add(parentId)); }; const handleCreate = async () => { if (!client || !newFolderName.trim()) return; setIsLoading(true); try { await createMailbox(client, newFolderName.trim(), creatingParentId ?? undefined); setNewFolderName(''); setIsCreating(false); setCreatingParentId(null); toast.success(t('folder_created')); } catch { toast.error(t('error_create')); } finally { setIsLoading(false); } }; const handleRename = async (mailboxId: string) => { if (!client || !editingName.trim()) return; setIsLoading(true); try { await renameMailbox(client, mailboxId, editingName.trim()); setEditingId(null); setEditingName(''); toast.success(t('folder_renamed')); } catch { toast.error(t('error_rename')); } finally { setIsLoading(false); } }; const handleDelete = async (mailboxId: string) => { if (!client) return; setIsLoading(true); try { await deleteMailbox(client, mailboxId); setDeletingId(null); toast.success(t('folder_deleted')); } catch (err: unknown) { const jmapType = (err as Error & { jmapType?: string })?.jmapType; switch (jmapType) { case 'mailboxHasChild': toast.error(t('error_delete_has_children')); break; case 'mailboxHasEmail': toast.error(t('error_delete_has_email')); break; default: toast.error(t('error_delete')); } } finally { setIsLoading(false); } }; const handleRoleChange = async (role: string, mailboxId: string) => { if (!client) return; setIsLoading(true); try { if (mailboxId === '') { const current = ownMailboxes.find(m => m.role === role); if (current) { await setMailboxRole(client, current.id, null); } } else { await setMailboxRole(client, mailboxId, role); } toast.success(t('role_updated')); } catch { toast.error(t('error_role')); } finally { setIsLoading(false); } }; const startEdit = (mb: { id: string; name: string }) => { setEditingId(mb.id); setEditingName(mb.name); }; const cancelEdit = () => { setEditingId(null); setEditingName(''); }; const renderCreateInline = (parentId: string | null, depth: number) => { if (!isCreating || creatingParentId !== parentId) return null; const parentName = parentId ? ownMailboxes.find(m => m.id === parentId)?.name : null; return (
{parentName && ( {t('subfolder_of', { name: parentName })} )} setNewFolderName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setIsCreating(false); setNewFolderName(''); setCreatingParentId(null); } }} placeholder={parentId ? t('subfolder_name') : t('new_folder_name')} className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" autoFocus disabled={isLoading} />
); }; const renderFolderNode = (item: FlatFolder): React.ReactNode => { const mb = item.node; const hasChildren = mb.children.length > 0; const isExpanded = expandedFolders.has(item.id); const isSelected = selectedIds.has(item.id); // The dragged row previews its projected nesting depth, so a horizontal // drag shows which parent the folder will land under. const depth = activeDragId === item.id && projected ? projected.depth : item.depth; const Icon = getIconForMailbox(mb); if (editingId === mb.id) { return (
setEditingName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleRename(mb.id); if (e.key === 'Escape') cancelEdit(); }} className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" autoFocus disabled={isLoading} />
); } if (deletingId === mb.id) { return (

{t('confirm_delete', { name: mb.name })}

); } return (
{mb.myRights?.mayRename ? ( ) : ( )} {/* Expand/collapse toggle for folders with children */} {hasChildren ? ( ) : ( )}
{folderIconsAllowed ? ( ) : ( )} {folderIconsAllowed && iconPickerId === mb.id && ( { setFolderIcon(mb.id, iconName); setIconPickerId(null); }} onClose={() => setIconPickerId(null)} /> )}
{mb.name} {mb.role && ( {t(`role_${mb.role}`)} )} {mb.unreadEmails > 0 && ( {mb.unreadEmails} )}
{mb.myRights?.mayCreateChild && ( )} {mb.myRights?.mayRename && ( )} {mb.myRights?.mayDelete && !mb.role && ( )}
{/* Inline subfolder creation (children follow as flattened rows) */} {renderCreateInline(mb.id, depth + 1)}
); }; return (
{/* Folder List - primary section */} {selectedCount > 0 && (
{t('selected_count', { count: selectedCount })} handleRoleChange(role, value)} options={[ { value: '', label: t('role_none') }, ...orderedMailboxes.map(mb => ({ value: mb.id, label: (nameCounts.get(mb.name) || 0) > 1 ? `${getParentPath(mb)}${mb.name} (${mb.id.slice(-6)})` : mb.name, })), ]} /> ); })}
); }