feat(mail): 为侧边栏添加右键菜单操作

- 支持对侧边栏条目打开、刷新和新建文件夹。
- 为自定义文件夹增加置顶、上移、下移、置底操作。
This commit is contained in:
LanQin_
2026-06-25 15:50:39 +08:00
parent d2a762a426
commit 361daf8693
+120 -1
View File
@@ -67,6 +67,7 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
type MailNotificationState = { latestId: string; latestReceivedAt: string }
type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void }
type MessageContextMenuState = { message: MailMessage; x: number; y: number }
type SidebarContextMenuState = { item: MailMenuItem; x: number; y: number }
type FolderDropTarget = { key: string; edge: "before" | "after" | "end" }
type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
@@ -113,6 +114,7 @@ export function MailPage() {
const [labelEditMode, setLabelEditMode] = React.useState(false)
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
const [messageContextMenu, setMessageContextMenu] = React.useState<MessageContextMenuState | null>(null)
const [sidebarContextMenu, setSidebarContextMenu] = React.useState<SidebarContextMenuState | null>(null)
const [folderDialogOpen, setFolderDialogOpen] = React.useState(false)
const [draggingFolderId, setDraggingFolderId] = React.useState("")
const [folderDropTarget, setFolderDropTarget] = React.useState<FolderDropTarget | null>(null)
@@ -676,6 +678,20 @@ export function MailPage() {
function closeMessageContextMenu() {
setMessageContextMenu(null)
}
function openSidebarContextMenu(event: React.MouseEvent, item: MailMenuItem) {
event.preventDefault()
event.stopPropagation()
setSidebarContextMenu({ item, x: event.clientX, y: event.clientY })
}
function closeSidebarContextMenu() {
setSidebarContextMenu(null)
}
function activateSidebarItem(item: MailMenuItem) {
if (item.type === "starred") openStarred()
else if (item.type === "scheduled") openScheduled()
else if (item.type === "sendQueue") openSendQueue()
else openFolder(item.folderName)
}
function reorderCustomFolder(draggedId: string, target: FolderDropTarget) {
if (!canOrganizeMail || reorderFolders.isPending) return
const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item]))
@@ -704,6 +720,23 @@ export function MailPage() {
const nextFolders = assignCustomFolderOrders(nextMenu)
reorderFolders.mutate(nextFolders)
}
function moveSidebarFolder(item: MailMenuItem, action: "top" | "up" | "down" | "bottom") {
if (item.type !== "folder" || !item.custom) return
const currentIndex = mailMenuItems.findIndex((entry) => entry.key === item.key)
if (currentIndex < 0) return
if (action === "top") {
reorderCustomFolder(item.folderId, { key: mailMenuItems[0]?.key || "__end__", edge: "before" })
return
}
if (action === "bottom") {
reorderCustomFolder(item.folderId, { key: "__end__", edge: "end" })
return
}
const targetIndex = action === "up" ? currentIndex - 1 : currentIndex + 1
const target = mailMenuItems[targetIndex]
if (!target) return
reorderCustomFolder(item.folderId, { key: target.key, edge: action === "up" ? "before" : "after" })
}
function handleFolderDragStart(event: React.DragEvent, item: MailMenuItem) {
if (item.type !== "folder" || !item.custom || sidebarCollapsed || !canOrganizeMail) return
event.dataTransfer.effectAllowed = "move"
@@ -875,6 +908,7 @@ export function MailPage() {
onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }}
onDrop={(event) => handleFolderDrop(event, item)}
onDragEnd={clearFolderDragState}
onContextMenu={(event) => openSidebarContextMenu(event, item)}
>
<SidebarMenuButton
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
@@ -886,7 +920,7 @@ export function MailPage() {
folderDropTarget?.key === item.key && folderDropTarget.edge === "before" && "border-t-2 border-t-primary",
folderDropTarget?.key === item.key && folderDropTarget.edge === "after" && "border-b-2 border-b-primary"
)}
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
onClick={() => activateSidebarItem(item)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
@@ -1241,6 +1275,28 @@ export function MailPage() {
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
}}
/>
<SidebarContextMenu
state={sidebarContextMenu}
canOrganize={canOrganizeMail}
pending={reorderFolders.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
closeSidebarContextMenu()
activateSidebarItem(item)
}}
onRefresh={() => {
closeSidebarContextMenu()
void refreshMailData()
}}
onCreateFolder={() => {
closeSidebarContextMenu()
setFolderDialogOpen(true)
}}
onMove={(item, action) => {
closeSidebarContextMenu()
moveSidebarFolder(item, action)
}}
/>
<CreateFolderDialog
open={folderDialogOpen}
pending={createFolder.isPending}
@@ -1665,6 +1721,69 @@ function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (ac
)
}
function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove }: { state: SidebarContextMenuState | null; canOrganize: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void }) {
React.useEffect(() => {
if (!state) return
const close = () => onClose()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("pointerdown", close)
window.addEventListener("resize", close)
window.addEventListener("scroll", close, true)
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("pointerdown", close)
window.removeEventListener("resize", close)
window.removeEventListener("scroll", close, true)
window.removeEventListener("keydown", onKeyDown)
}
}, [state, onClose])
if (!state) return null
const item = state.item
const customFolder = item.type === "folder" && item.custom
const position = contextMenuPosition(state.x, state.y)
return (
<div
className="fixed z-[110] w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md"
style={{ left: position.x, top: position.y }}
onPointerDown={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
role="menu"
>
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent" onClick={() => onOpen(item)}>
<Inbox className="h-4 w-4" />
</button>
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent" onClick={onRefresh}>
<RefreshCcw className="h-4 w-4" />
</button>
{canOrganize && (
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent" onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</button>
)}
{canOrganize && customFolder && (
<>
<div className="my-1 h-px bg-border" />
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</button>
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</button>
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</button>
<button type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</button>
</>
)}
</div>
)
}
function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canManageLabels, labelPending, onClose, onAction, onMoveToFolder, onToggleLabel }: { state: MessageContextMenuState | null; labels: MailLabel[]; folders: MailFolder[]; canSend: boolean; canOrganize: boolean; canManageLabels: boolean; labelPending: boolean; onClose: () => void; onAction: (action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) => void; onMoveToFolder: (message: MailMessage, folderName: string) => void; onToggleLabel: (message: MailMessage, label: MailLabel) => void }) {
React.useEffect(() => {
if (!state) return