diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index ac1b2959..225026fb 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1407,6 +1407,13 @@ export default function Home() { currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} + mailboxes={mailboxes} + selectedMailbox={selectedMailbox} + onMoveToMailbox={async (mailboxId) => { + if (client && selectedEmail) { + await moveToMailbox(client, selectedEmail.id, mailboxId); + } + }} className={isMobile ? "flex-1" : undefined} /> diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f4d8987b..411b8e9c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2,11 +2,11 @@ import { useState, useEffect, useMemo, useRef } from "react"; import DOMPurify from "dompurify"; -import { Email, ContactCard } from "@/lib/jmap/types"; +import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; -import { formatFileSize, cn } from "@/lib/utils"; +import { formatFileSize, cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { Reply, @@ -53,6 +53,9 @@ import { StickyNote, PanelRightClose, Send, + FolderInput, + Inbox, + Folder, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -84,11 +87,14 @@ interface EmailViewerProps { onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; + onMoveToMailbox?: (mailboxId: string) => void; onBack?: () => void; onShowShortcuts?: () => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: string; + mailboxes?: Mailbox[]; + selectedMailbox?: string; className?: string; } @@ -333,7 +339,7 @@ function ContactSidebarPanel({
{o.name} {o.units && o.units.length > 0 && ( - — {o.units.map(u => u.name).join(", ")} + — {o.units.map(u => u.name).join(", ")} )}
))} @@ -402,11 +408,14 @@ export function EmailViewer({ onQuickReply, onMarkAsSpam, onUndoSpam, + onMoveToMailbox, onBack, onShowShortcuts, currentUserEmail, currentUserName, currentMailboxRole, + mailboxes = [], + selectedMailbox = "", className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); @@ -443,13 +452,54 @@ export function EmailViewer({ const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [tagMenuOpen, setTagMenuOpen] = useState(false); + const [moveMenuOpen, setMoveMenuOpen] = useState(false); const moreMenuRef = useRef(null); const tagMenuRef = useRef(null); + const moveMenuRef = useRef(null); const currentColor = getCurrentColor(email?.keywords); + // Build mailbox tree for move-to dropdown + const moveTargetIds = useMemo(() => new Set( + mailboxes + .filter( + (m) => + m.id !== selectedMailbox && + m.role !== "drafts" && + !m.id.startsWith("shared-") && + m.myRights?.mayAddItems + ) + .map((m) => m.id) + ), [mailboxes, selectedMailbox]); + + const moveTree = useMemo(() => { + const tree = buildMailboxTree(mailboxes); + const filterTree = (nodes: MailboxNode[]): MailboxNode[] => { + return nodes.reduce((acc, node) => { + const filteredChildren = filterTree(node.children); + if (moveTargetIds.has(node.id) || filteredChildren.length > 0) { + acc.push({ ...node, children: filteredChildren }); + } + return acc; + }, []); + }; + return filterTree(tree); + }, [mailboxes, moveTargetIds]); + + // Get mailbox icon based on role + const getMoveMailboxIcon = (role?: string) => { + switch (role) { + case "inbox": return Inbox; + case "sent": return Send; + case "drafts": return File; + case "trash": return Trash2; + case "archive": return Archive; + default: return Folder; + } + }; + // Close dropdown menus on click outside useEffect(() => { - if (!moreMenuOpen && !tagMenuOpen) return; + if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return; function handleClickOutside(e: MouseEvent) { if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setMoreMenuOpen(false); @@ -457,15 +507,19 @@ export function EmailViewer({ if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) { setTagMenuOpen(false); } + if (moveMenuOpen && moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) { + setMoveMenuOpen(false); + } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, [moreMenuOpen, tagMenuOpen]); + }, [moreMenuOpen, tagMenuOpen, moveMenuOpen]); // Close dropdowns when email changes useEffect(() => { setMoreMenuOpen(false); setTagMenuOpen(false); + setMoveMenuOpen(false); }, [email?.id]); // Contact sidebar state @@ -1120,6 +1174,55 @@ export function EmailViewer({ )} )} + {/* Move to folder - hidden on mobile, available in More menu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} )} - {/* Tag submenu on mobile */} + {/* Move to folder submenu on mobile */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+
+ )} {/* Tag submenu on mobile */} {colorOptions.length > 0 && (
@@ -1449,6 +1591,55 @@ export function EmailViewer({ )} )} + {/* Move to folder - hidden on mobile, available in More menu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} )} - {/* Tag submenu on mobile */} + {/* Move to folder submenu on mobile */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+
+ )} {/* Tag submenu on mobile */} {colorOptions.length > 0 && (
@@ -2216,12 +2446,12 @@ export function EmailViewer({ }} /> )} - · + · )} {email.to && email.to.length > 0 && ( <> - → {t('recipient_to_prefix')} + → {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )} diff --git a/locales/de/common.json b/locales/de/common.json index b49bb8da..51d0ae88 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -175,6 +175,7 @@ "set_color": "Label setzen", "tag": "Label", "more_actions": "Weitere Aktionen", + "move_to": "Verschieben nach...", "remove_color": "Label entfernen", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", diff --git a/locales/en/common.json b/locales/en/common.json index 7a0141be..20be071b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -175,6 +175,7 @@ "set_color": "Set tag", "tag": "Tag", "more_actions": "More actions", + "move_to": "Move to...", "remove_color": "Remove tag", "more_count": "+{count} more", "characters_count": "{count} characters", diff --git a/locales/es/common.json b/locales/es/common.json index 8cb4941a..29f914ab 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -175,6 +175,7 @@ "set_color": "Establecer etiqueta", "tag": "Etiqueta", "more_actions": "Más acciones", + "move_to": "Mover a...", "remove_color": "Eliminar etiqueta", "more_count": "+{count} más", "characters_count": "{count} caracteres", diff --git a/locales/fr/common.json b/locales/fr/common.json index b6198658..de397397 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -175,6 +175,7 @@ "set_color": "Définir l'étiquette", "tag": "Étiquette", "more_actions": "Plus d'actions", + "move_to": "Déplacer vers...", "remove_color": "Retirer l'étiquette", "more_count": "+{count} de plus", "characters_count": "{count} caractères", diff --git a/locales/it/common.json b/locales/it/common.json index 76bf97df..93657688 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -175,6 +175,7 @@ "set_color": "Imposta etichetta", "tag": "Etichetta", "more_actions": "Altre azioni", + "move_to": "Sposta in...", "remove_color": "Rimuovi etichetta", "more_count": "+{count} altri", "characters_count": "{count} caratteri", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7287112e..e90088cc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -175,6 +175,7 @@ "set_color": "ラベルを設定", "tag": "ラベル", "more_actions": "その他の操作", + "move_to": "移動...", "remove_color": "ラベルを削除", "more_count": "他{count}件", "characters_count": "{count}文字", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9c3d7972..72d21257 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -175,6 +175,7 @@ "set_color": "Label instellen", "tag": "Label", "more_actions": "Meer acties", + "move_to": "Verplaatsen naar...", "remove_color": "Label verwijderen", "more_count": "+{count} meer", "characters_count": "{count} tekens", diff --git a/locales/pt/common.json b/locales/pt/common.json index 562eba64..e9952e12 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -175,6 +175,7 @@ "set_color": "Definir etiqueta", "tag": "Etiqueta", "more_actions": "Mais ações", + "move_to": "Mover para...", "remove_color": "Remover etiqueta", "more_count": "+{count} mais", "characters_count": "{count} caracteres",