454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
type ReactNode,
|
|
} from "react"
|
|
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
|
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
|
|
import {
|
|
useNavStore,
|
|
type LabelListSidebarVisibility,
|
|
type LabelInMessageListVisibility,
|
|
type NavItemPrefs,
|
|
} from "@/lib/stores/nav-store"
|
|
import {
|
|
buildEmailLabelToSidebarFolderId,
|
|
buildFolderIdToLabelRecord,
|
|
} from "@/lib/sidebar-nav-maps"
|
|
import { useSidebarNavApiSync } from "@/lib/hooks/use-sidebar-nav-api"
|
|
import { isServerNavId } from "@/lib/sidebar-nav-server-id"
|
|
import { DEFAULT_NAV_COLOR } from "@/lib/nav-color"
|
|
import { findFolderNodeById } from "@/lib/sidebar-folder-tree-utils"
|
|
import {
|
|
folderTreeToReorderItems,
|
|
labelRowsToReorderItems,
|
|
} from "@/lib/nav-reorder-plan"
|
|
|
|
export type { LabelListSidebarVisibility, LabelInMessageListVisibility, NavItemPrefs }
|
|
|
|
export type NavEmailSync = {
|
|
renameLabel: (from: string, to: string) => void
|
|
removeLabel: (label: string) => void
|
|
}
|
|
|
|
const navEmailSyncRef = { current: null as NavEmailSync | null }
|
|
|
|
export function registerNavEmailSync(cb: NavEmailSync | null) {
|
|
navEmailSyncRef.current = cb
|
|
}
|
|
|
|
type SidebarNavContextValue = {
|
|
folderTree: FolderTreeNode[]
|
|
labelRows: LabelRowItem[]
|
|
folderIdToLabel: Record<string, string>
|
|
emailLabelToSidebarFolderId: Record<string, string>
|
|
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
|
|
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
|
|
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
|
|
ensureLabelRowForLabelText: (label: string) => void
|
|
addLabelRowFromSidebar: (label: string, color?: string) => void
|
|
addFolder: (parentId: string | null, name: string) => void
|
|
updateFolderOrLabelColor: (id: string, color: string) => void
|
|
renameFolderOrLabel: (id: string, newLabel: string) => void
|
|
removeFolderOrLabelRow: (id: string) => void
|
|
moveFolder: (id: string, newParentId: string | null) => void
|
|
reorderLabelRows: (
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after"
|
|
) => void
|
|
moveFolderRelative: (
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after" | "inside"
|
|
) => void
|
|
addSubfolder: (parentId: string, name: string) => void
|
|
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
|
setLabelRowEnabled: (id: string, enabled: boolean) => void
|
|
}
|
|
|
|
const SidebarNavContext = createContext<SidebarNavContextValue | null>(null)
|
|
|
|
type SidebarNavProviderProps = {
|
|
children: ReactNode
|
|
routeFolderId?: string | null
|
|
onRouteFolderIdChange?: (nextFolderId: string) => void
|
|
}
|
|
|
|
export function SidebarNavProvider({
|
|
children,
|
|
routeFolderId,
|
|
onRouteFolderIdChange,
|
|
}: SidebarNavProviderProps) {
|
|
const routeSyncRef = useRef({
|
|
routeFolderId: null as string | null | undefined,
|
|
onRouteFolderIdChange: undefined as
|
|
| ((nextFolderId: string) => void)
|
|
| undefined,
|
|
})
|
|
useEffect(() => {
|
|
routeSyncRef.current = { routeFolderId, onRouteFolderIdChange }
|
|
}, [routeFolderId, onRouteFolderIdChange])
|
|
|
|
const navApi = useSidebarNavApiSync()
|
|
|
|
const scheduleRouteFolderIdSync = useCallback(
|
|
(idMap: Record<string, string>) => {
|
|
if (Object.keys(idMap).length === 0) return
|
|
queueMicrotask(() => {
|
|
const { routeFolderId: rid, onRouteFolderIdChange: on } =
|
|
routeSyncRef.current
|
|
if (!rid || !on) return
|
|
const next = idMap[rid]
|
|
if (next) on(next)
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
const folderTree = useNavStore((s) => s.folderTree)
|
|
const labelRows = useNavStore((s) => s.labelRows)
|
|
const navItemPrefs = useNavStore((s) => s.navItemPrefs)
|
|
const navActions = useNavStore.getState()
|
|
|
|
const folderIdToLabel = useMemo(
|
|
() => buildFolderIdToLabelRecord(folderTree, labelRows),
|
|
[folderTree, labelRows]
|
|
)
|
|
|
|
const emailLabelToSidebarFolderId = useMemo(
|
|
() => buildEmailLabelToSidebarFolderId(folderIdToLabel),
|
|
[folderIdToLabel]
|
|
)
|
|
|
|
const getNavItemPrefs = useCallback(
|
|
(id: string): Required<Pick<NavItemPrefs, "sidebar" | "messages">> => {
|
|
const p = navItemPrefs[id]
|
|
return {
|
|
sidebar: p?.sidebar ?? "show",
|
|
messages: p?.messages ?? "show",
|
|
}
|
|
},
|
|
[navItemPrefs]
|
|
)
|
|
|
|
const isFolderId = useCallback(
|
|
(id: string) => findFolderNodeById(folderTree, id) != null,
|
|
[folderTree]
|
|
)
|
|
|
|
const getLabelRow = useCallback(
|
|
(id: string) => labelRows.find((r) => r.id === id),
|
|
[labelRows]
|
|
)
|
|
|
|
const addFolder = useCallback(
|
|
(parentId: string | null, name: string) => {
|
|
if (navApi.apiEnabled) {
|
|
navApi.createFolder({
|
|
name,
|
|
color: "bg-slate-400",
|
|
parent_id: parentId ?? undefined,
|
|
})
|
|
return
|
|
}
|
|
navActions.addFolder(parentId, name)
|
|
},
|
|
[navApi]
|
|
)
|
|
|
|
const addSubfolder = useCallback(
|
|
(parentId: string, name: string) => {
|
|
addFolder(parentId, name)
|
|
},
|
|
[addFolder]
|
|
)
|
|
|
|
const addLabelRowFromSidebar = useCallback(
|
|
(label: string, color = DEFAULT_NAV_COLOR) => {
|
|
if (navApi.apiEnabled) {
|
|
navApi.createLabel({ name: label.trim(), color })
|
|
return
|
|
}
|
|
navActions.addLabelRowFromSidebar(label)
|
|
},
|
|
[navApi]
|
|
)
|
|
|
|
const addChildLabelRow = useCallback(
|
|
(parentLabelRowId: string, childName: string) => {
|
|
const parent = getLabelRow(parentLabelRowId)
|
|
const child = childName.trim()
|
|
if (!child) return
|
|
const fullName = parent ? `${parent.label}/${child}` : child
|
|
if (navApi.apiEnabled) {
|
|
navApi.createLabel({
|
|
name: fullName,
|
|
color: parent?.color ?? DEFAULT_NAV_COLOR,
|
|
})
|
|
return
|
|
}
|
|
navActions.addChildLabelRow(parentLabelRowId, childName)
|
|
},
|
|
[getLabelRow, navApi]
|
|
)
|
|
|
|
const updateFolderOrLabelColor = useCallback(
|
|
(id: string, color: string) => {
|
|
if (navApi.apiEnabled && isServerNavId(id)) {
|
|
if (isFolderId(id)) {
|
|
const node = findFolderNodeById(folderTree, id)
|
|
if (node) {
|
|
navApi.updateFolder({
|
|
id,
|
|
name: node.label,
|
|
color,
|
|
parent_id: undefined,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
const row = getLabelRow(id)
|
|
if (row && !isSystemNavLabelId(id)) {
|
|
navApi.updateLabel({ id, name: row.label, color })
|
|
return
|
|
}
|
|
}
|
|
navActions.updateFolderOrLabelColor(id, color)
|
|
},
|
|
[folderTree, getLabelRow, isFolderId, navApi]
|
|
)
|
|
|
|
const renameFolderOrLabel = useCallback(
|
|
(id: string, newLabel: string) => {
|
|
if (navApi.apiEnabled && isServerNavId(id)) {
|
|
if (isFolderId(id)) {
|
|
const node = findFolderNodeById(folderTree, id)
|
|
if (node) {
|
|
navApi.updateFolder({
|
|
id,
|
|
name: newLabel.trim(),
|
|
color: node.color ?? "bg-slate-400",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
const row = getLabelRow(id)
|
|
if (row && !isSystemNavLabelId(id)) {
|
|
const oldLabel = row.label
|
|
navApi.updateLabel({ id, name: newLabel.trim(), color: row.color })
|
|
queueMicrotask(() => {
|
|
navEmailSyncRef.current?.renameLabel(oldLabel, newLabel.trim())
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
const { idMap, emailRename } = useNavStore.getState().renameFolderOrLabel(id, newLabel)
|
|
scheduleRouteFolderIdSync(idMap)
|
|
if (emailRename) {
|
|
queueMicrotask(() => {
|
|
navEmailSyncRef.current?.renameLabel(emailRename.from, emailRename.to)
|
|
})
|
|
}
|
|
},
|
|
[folderTree, getLabelRow, isFolderId, navApi, scheduleRouteFolderIdSync]
|
|
)
|
|
|
|
const removeFolderOrLabelRow = useCallback(
|
|
(id: string) => {
|
|
if (navApi.apiEnabled && isServerNavId(id)) {
|
|
if (isFolderId(id)) {
|
|
navApi.deleteFolder(id)
|
|
return
|
|
}
|
|
const row = getLabelRow(id)
|
|
if (row && !isSystemNavLabelId(id)) {
|
|
navApi.deleteLabel(id)
|
|
queueMicrotask(() => {
|
|
navEmailSyncRef.current?.removeLabel(row.label)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
const labelsToRemove = useNavStore.getState().removeFolderOrLabelRow(id)
|
|
if (labelsToRemove.length > 0) {
|
|
queueMicrotask(() => {
|
|
for (const lab of labelsToRemove) {
|
|
navEmailSyncRef.current?.removeLabel(lab)
|
|
}
|
|
})
|
|
}
|
|
},
|
|
[getLabelRow, isFolderId, navApi]
|
|
)
|
|
|
|
const syncLabelOrder = useCallback(() => {
|
|
if (!navApi.apiEnabled) return
|
|
const items = labelRowsToReorderItems(useNavStore.getState().labelRows)
|
|
navApi.reorderLabels(items)
|
|
}, [navApi])
|
|
|
|
const syncFolderOrder = useCallback(() => {
|
|
if (!navApi.apiEnabled) return
|
|
const items = folderTreeToReorderItems(useNavStore.getState().folderTree)
|
|
navApi.reorderFolders(items)
|
|
}, [navApi])
|
|
|
|
const moveFolder = useCallback(
|
|
(id: string, newParentId: string | null) => {
|
|
if (navApi.apiEnabled && isServerNavId(id)) {
|
|
const node = findFolderNodeById(folderTree, id)
|
|
if (node) {
|
|
useNavStore.getState().moveFolder(id, newParentId)
|
|
queueMicrotask(syncFolderOrder)
|
|
return
|
|
}
|
|
}
|
|
const idMap = useNavStore.getState().moveFolder(id, newParentId)
|
|
scheduleRouteFolderIdSync(idMap)
|
|
},
|
|
[folderTree, navApi, scheduleRouteFolderIdSync, syncFolderOrder]
|
|
)
|
|
|
|
const moveFolderRelative = useCallback(
|
|
(
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after" | "inside"
|
|
) => {
|
|
if (navApi.apiEnabled && isServerNavId(draggedId)) {
|
|
const idMap = useNavStore
|
|
.getState()
|
|
.moveFolderRelative(draggedId, targetId, placement)
|
|
scheduleRouteFolderIdSync(idMap)
|
|
queueMicrotask(syncFolderOrder)
|
|
return
|
|
}
|
|
const idMap = useNavStore
|
|
.getState()
|
|
.moveFolderRelative(draggedId, targetId, placement)
|
|
scheduleRouteFolderIdSync(idMap)
|
|
},
|
|
[navApi, scheduleRouteFolderIdSync, syncFolderOrder]
|
|
)
|
|
|
|
const reorderLabelRows = useCallback(
|
|
(
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after"
|
|
) => {
|
|
useNavStore.getState().reorderLabelRows(draggedId, targetId, placement)
|
|
queueMicrotask(syncLabelOrder)
|
|
},
|
|
[syncLabelOrder]
|
|
)
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
folderTree,
|
|
labelRows,
|
|
folderIdToLabel,
|
|
emailLabelToSidebarFolderId,
|
|
getNavItemPrefs,
|
|
setNavItemSidebarVisibility: navActions.setNavItemSidebarVisibility,
|
|
setNavItemMessageVisibility: navActions.setNavItemMessageVisibility,
|
|
ensureLabelRowForLabelText: navActions.ensureLabelRowForLabelText,
|
|
addLabelRowFromSidebar,
|
|
addFolder,
|
|
updateFolderOrLabelColor,
|
|
renameFolderOrLabel,
|
|
removeFolderOrLabelRow,
|
|
moveFolder,
|
|
reorderLabelRows,
|
|
moveFolderRelative,
|
|
addSubfolder,
|
|
addChildLabelRow,
|
|
setLabelRowEnabled: navActions.setLabelRowEnabled,
|
|
}),
|
|
[
|
|
folderTree,
|
|
labelRows,
|
|
folderIdToLabel,
|
|
emailLabelToSidebarFolderId,
|
|
getNavItemPrefs,
|
|
navActions,
|
|
addLabelRowFromSidebar,
|
|
addFolder,
|
|
updateFolderOrLabelColor,
|
|
renameFolderOrLabel,
|
|
removeFolderOrLabelRow,
|
|
moveFolder,
|
|
reorderLabelRows,
|
|
moveFolderRelative,
|
|
addSubfolder,
|
|
addChildLabelRow,
|
|
]
|
|
)
|
|
|
|
return (
|
|
<SidebarNavContext.Provider value={value}>
|
|
{children}
|
|
</SidebarNavContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSidebarNav(): SidebarNavContextValue {
|
|
const ctx = useContext(SidebarNavContext)
|
|
if (!ctx) {
|
|
throw new Error("useSidebarNav must be used within SidebarNavProvider")
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
export function folderMoveParentOptions(
|
|
tree: FolderTreeNode[],
|
|
excludeId: string
|
|
): { value: string; label: string; depth: number }[] {
|
|
function findNode(nodes: FolderTreeNode[], id: string): FolderTreeNode | null {
|
|
for (const n of nodes) {
|
|
if (n.id === id) return n
|
|
if (n.children?.length) {
|
|
const hit = findNode(n.children, id)
|
|
if (hit) return hit
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
function collectIds(node: FolderTreeNode): Set<string> {
|
|
const s = new Set<string>([node.id])
|
|
if (node.children?.length) {
|
|
for (const c of node.children) {
|
|
for (const x of collectIds(c)) s.add(x)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
const ex = findNode(tree, excludeId)
|
|
const banned = ex ? collectIds(ex) : new Set([excludeId])
|
|
const out: { value: string; label: string; depth: number }[] = [
|
|
{ value: "__root__", label: "Racine", depth: 0 },
|
|
]
|
|
const walk = (nodes: FolderTreeNode[], depth: number) => {
|
|
for (const n of nodes) {
|
|
if (banned.has(n.id)) continue
|
|
out.push({
|
|
value: n.id,
|
|
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
|
|
depth,
|
|
})
|
|
if (n.children?.length) walk(n.children, depth + 1)
|
|
}
|
|
}
|
|
walk(tree, 0)
|
|
return out
|
|
}
|