feat(mail): 为侧边栏添加右键菜单操作
- 支持对侧边栏条目打开、刷新和新建文件夹。 - 为自定义文件夹增加置顶、上移、下移、置底操作。
This commit is contained in:
+120
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user