609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { create } from "zustand"
|
|
import { persist } from "zustand/middleware"
|
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
|
import {
|
|
cloneDefaultFolderTree,
|
|
cloneDefaultLabelRows,
|
|
isSystemNavLabelId,
|
|
normalizeLabelRow,
|
|
reconcileLabelRowsFromPersisted,
|
|
type FolderTreeNode,
|
|
type LabelRowItem,
|
|
} from "@/lib/sidebar-nav-data"
|
|
import {
|
|
collectFolderIdsInTree,
|
|
proposeNewFolderId,
|
|
rekeyFolderSubtreeAt,
|
|
slugifyNavSegment,
|
|
} from "@/lib/sidebar-nav-folder-ids"
|
|
import {
|
|
buildEmailLabelToSidebarFolderId,
|
|
buildFolderIdToLabelRecord,
|
|
} from "@/lib/sidebar-nav-maps"
|
|
|
|
export type LabelListSidebarVisibility = "show" | "showUnread" | "hide"
|
|
export type LabelInMessageListVisibility = "show" | "hide"
|
|
|
|
export type NavItemPrefs = {
|
|
sidebar?: LabelListSidebarVisibility
|
|
messages?: LabelInMessageListVisibility
|
|
}
|
|
|
|
type NavStoreState = {
|
|
folderTree: FolderTreeNode[]
|
|
labelRows: LabelRowItem[]
|
|
navItemPrefs: Record<string, NavItemPrefs>
|
|
}
|
|
|
|
function newLabelRowId(label: string): string {
|
|
const slug = slugifyNavSegment(label)
|
|
return `lbl-${slug || "libelle"}`
|
|
}
|
|
|
|
function allLabelTextsLower(tree: FolderTreeNode[], rows: LabelRowItem[]): Set<string> {
|
|
const s = new Set<string>()
|
|
const walk = (nodes: FolderTreeNode[]) => {
|
|
for (const n of nodes) {
|
|
s.add(n.label.toLowerCase())
|
|
if (n.children?.length) walk(n.children)
|
|
}
|
|
}
|
|
walk(tree)
|
|
for (const r of rows) s.add(r.label.toLowerCase())
|
|
return s
|
|
}
|
|
|
|
function insertFolderChild(
|
|
nodes: FolderTreeNode[],
|
|
parentId: string | null,
|
|
child: FolderTreeNode
|
|
): FolderTreeNode[] {
|
|
if (parentId === null) return [...nodes, child]
|
|
return nodes.map((n) => {
|
|
if (n.id === parentId) return { ...n, children: [...(n.children ?? []), child] }
|
|
if (n.children?.length) return { ...n, children: insertFolderChild(n.children, parentId, child) }
|
|
return n
|
|
})
|
|
}
|
|
|
|
function uniqueLabelRowId(base: string, existingIds: Set<string>): string {
|
|
let id = base
|
|
let n = 0
|
|
while (existingIds.has(id)) {
|
|
n += 1
|
|
id = `${base}-${n}`
|
|
}
|
|
return id
|
|
}
|
|
|
|
function findNodeInTree(nodes: FolderTreeNode[], id: string): FolderTreeNode | null {
|
|
for (const n of nodes) {
|
|
if (n.id === id) return n
|
|
if (n.children?.length) {
|
|
const hit = findNodeInTree(n.children, id)
|
|
if (hit) return hit
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function collectSubtreeIds(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 collectSubtreeIds(c)) s.add(x)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
function isDescendantOf(tree: FolderTreeNode[], maybeDescendantId: string, ancestorId: string): boolean {
|
|
const anc = findNodeInTree(tree, ancestorId)
|
|
if (!anc) return false
|
|
return collectSubtreeIds(anc).has(maybeDescendantId)
|
|
}
|
|
|
|
type FolderNodeLocation = {
|
|
parentId: string | null
|
|
index: number
|
|
}
|
|
|
|
function findNodeLocation(
|
|
nodes: FolderTreeNode[],
|
|
id: string,
|
|
parentId: string | null = null
|
|
): FolderNodeLocation | null {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].id === id) return { parentId, index: i }
|
|
const children = nodes[i].children
|
|
if (children?.length) {
|
|
const hit = findNodeLocation(children, id, nodes[i].id)
|
|
if (hit) return hit
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function insertNodeAtParentIndex(
|
|
nodes: FolderTreeNode[],
|
|
parentId: string | null,
|
|
index: number,
|
|
node: FolderTreeNode
|
|
): FolderTreeNode[] {
|
|
if (parentId === null) {
|
|
const next = [...nodes]
|
|
next.splice(Math.max(0, Math.min(index, next.length)), 0, node)
|
|
return next
|
|
}
|
|
return nodes.map((n) => {
|
|
if (n.id === parentId) {
|
|
const children = [...(n.children ?? [])]
|
|
children.splice(Math.max(0, Math.min(index, children.length)), 0, node)
|
|
return { ...n, children }
|
|
}
|
|
if (n.children?.length) {
|
|
return { ...n, children: insertNodeAtParentIndex(n.children, parentId, index, node) }
|
|
}
|
|
return n
|
|
})
|
|
}
|
|
|
|
function applyFolderTreeRekey(
|
|
tree: FolderTreeNode[],
|
|
movedNodeId: string,
|
|
labelRowIds: string[],
|
|
navItemPrefs: Record<string, NavItemPrefs>
|
|
): {
|
|
tree: FolderTreeNode[]
|
|
navItemPrefs: Record<string, NavItemPrefs>
|
|
idMap: Record<string, string>
|
|
} {
|
|
const rk = rekeyFolderSubtreeAt(tree, movedNodeId, labelRowIds)
|
|
const idMap = rk?.idMap ?? {}
|
|
if (rk && Object.keys(idMap).length > 0) {
|
|
return {
|
|
tree: rk.tree,
|
|
navItemPrefs: remapNavItemPrefs(navItemPrefs, idMap),
|
|
idMap,
|
|
}
|
|
}
|
|
return { tree, navItemPrefs, idMap: {} }
|
|
}
|
|
|
|
function extractNode(
|
|
nodes: FolderTreeNode[],
|
|
id: string
|
|
): { next: FolderTreeNode[]; extracted: FolderTreeNode | null } {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const n = nodes[i]
|
|
if (n.id === id) {
|
|
return { next: [...nodes.slice(0, i), ...nodes.slice(i + 1)], extracted: n }
|
|
}
|
|
if (n.children?.length) {
|
|
const inner = extractNode(n.children, id)
|
|
if (inner.extracted) {
|
|
return {
|
|
next: nodes.map((x, j) => (j === i ? { ...x, children: inner.next } : x)),
|
|
extracted: inner.extracted,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { next: nodes, extracted: null }
|
|
}
|
|
|
|
function updateNodeInTree(
|
|
nodes: FolderTreeNode[],
|
|
id: string,
|
|
patch: Partial<Pick<FolderTreeNode, "label" | "color">>
|
|
): FolderTreeNode[] {
|
|
return nodes.map((n) => {
|
|
if (n.id === id) return { ...n, ...patch }
|
|
if (n.children?.length) return { ...n, children: updateNodeInTree(n.children, id, patch) }
|
|
return n
|
|
})
|
|
}
|
|
|
|
function remapNavItemPrefs(
|
|
prefs: Record<string, NavItemPrefs>,
|
|
idMap: Record<string, string>
|
|
): Record<string, NavItemPrefs> {
|
|
if (Object.keys(idMap).length === 0) return prefs
|
|
const next = { ...prefs }
|
|
for (const [oldId, newId] of Object.entries(idMap)) {
|
|
const slice = next[oldId]
|
|
if (!slice) continue
|
|
delete next[oldId]
|
|
next[newId] = { ...next[newId], ...slice }
|
|
}
|
|
return next
|
|
}
|
|
|
|
type NavStoreActions = {
|
|
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
|
|
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
|
|
ensureLabelRowForLabelText: (raw: string) => void
|
|
addLabelRowFromSidebar: (raw: string) => void
|
|
addFolder: (parentId: string | null, name: string) => void
|
|
addSubfolder: (parentId: string, name: string) => void
|
|
updateFolderOrLabelColor: (id: string, color: string) => void
|
|
renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null }
|
|
removeFolderOrLabelRow: (id: string) => string[]
|
|
moveFolder: (id: string, newParentId: string | null) => Record<string, string>
|
|
reorderLabelRows: (
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after"
|
|
) => void
|
|
moveFolderRelative: (
|
|
draggedId: string,
|
|
targetId: string,
|
|
placement: "before" | "after" | "inside"
|
|
) => Record<string, string>
|
|
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
|
setLabelRowEnabled: (id: string, enabled: boolean) => void
|
|
|
|
/** Derived selectors */
|
|
getFolderIdToLabel: () => Record<string, string>
|
|
getEmailLabelToSidebarFolderId: () => Record<string, string>
|
|
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
|
|
|
|
hydrateFolderTreeFromApi: (tree: FolderTreeNode[]) => void
|
|
hydrateLabelRowsFromApi: (rows: LabelRowItem[]) => void
|
|
}
|
|
|
|
export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|
persist(
|
|
(set, get) => ({
|
|
folderTree: cloneDefaultFolderTree(),
|
|
labelRows: cloneDefaultLabelRows(),
|
|
navItemPrefs: {},
|
|
|
|
setNavItemSidebarVisibility: (id, v) =>
|
|
set((s) => ({
|
|
navItemPrefs: { ...s.navItemPrefs, [id]: { ...s.navItemPrefs[id], sidebar: v } },
|
|
})),
|
|
|
|
setNavItemMessageVisibility: (id, v) =>
|
|
set((s) => ({
|
|
navItemPrefs: { ...s.navItemPrefs, [id]: { ...s.navItemPrefs[id], messages: v } },
|
|
})),
|
|
|
|
ensureLabelRowForLabelText: (raw) => {
|
|
const label = raw.trim()
|
|
if (!label) return
|
|
set((s) => {
|
|
const known = allLabelTextsLower(s.folderTree, s.labelRows)
|
|
if (known.has(label.toLowerCase())) return s
|
|
const ids = new Set(s.labelRows.map((r) => r.id))
|
|
const id = uniqueLabelRowId(newLabelRowId(label), ids)
|
|
const row = normalizeLabelRow({
|
|
id,
|
|
label,
|
|
color: "bg-gray-500",
|
|
tabbed: false,
|
|
favorite: false,
|
|
excludeFromPrincipal: false,
|
|
showInMessageList: true,
|
|
enabled: true,
|
|
})
|
|
return { labelRows: [...s.labelRows, row] }
|
|
})
|
|
},
|
|
|
|
addLabelRowFromSidebar: (raw) => {
|
|
get().ensureLabelRowForLabelText(raw)
|
|
},
|
|
|
|
addFolder: (parentId, name) => {
|
|
const label = name.trim()
|
|
if (!label) return
|
|
set((s) => {
|
|
const fid = proposeNewFolderId(
|
|
s.folderTree,
|
|
s.labelRows.map((r) => r.id),
|
|
parentId,
|
|
label
|
|
)
|
|
const node: FolderTreeNode = { id: fid, label, color: "bg-slate-400" }
|
|
return { folderTree: insertFolderChild(s.folderTree, parentId, node) }
|
|
})
|
|
},
|
|
|
|
addSubfolder: (parentId, name) => {
|
|
get().addFolder(parentId, name)
|
|
},
|
|
|
|
updateFolderOrLabelColor: (id, color) =>
|
|
set((s) => {
|
|
const inRow = s.labelRows.some((r) => r.id === id)
|
|
if (inRow) {
|
|
return { labelRows: s.labelRows.map((r) => (r.id === id ? { ...r, color } : r)) }
|
|
}
|
|
return { folderTree: updateNodeInTree(s.folderTree, id, { color }) }
|
|
}),
|
|
|
|
renameFolderOrLabel: (id, raw) => {
|
|
const nextLabel = raw.trim()
|
|
if (!nextLabel) return { idMap: {}, emailRename: null }
|
|
|
|
const snap = get()
|
|
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
|
|
const oldLabelForSync = snapMap[id]
|
|
const emailRename =
|
|
oldLabelForSync && oldLabelForSync !== nextLabel
|
|
? { from: oldLabelForSync, to: nextLabel }
|
|
: null
|
|
|
|
let resultIdMap: Record<string, string> = {}
|
|
|
|
set((prev) => {
|
|
const inRow = prev.labelRows.some((r) => r.id === id)
|
|
let nextTree = prev.folderTree
|
|
let nextRows = prev.labelRows
|
|
let navItemPrefs = prev.navItemPrefs
|
|
if (inRow) {
|
|
if (isSystemNavLabelId(id)) {
|
|
nextRows = prev.labelRows.map((r) =>
|
|
r.id === id ? { ...r, label: nextLabel } : r
|
|
)
|
|
} else {
|
|
const usedIds = new Set([
|
|
...collectFolderIdsInTree(prev.folderTree),
|
|
...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id),
|
|
])
|
|
const newRowId = uniqueLabelRowId(newLabelRowId(nextLabel), usedIds)
|
|
const rowMap: Record<string, string> = newRowId !== id ? { [id]: newRowId } : {}
|
|
nextRows = prev.labelRows.map((r) =>
|
|
r.id === id ? { ...r, id: newRowId, label: nextLabel } : r
|
|
)
|
|
if (Object.keys(rowMap).length > 0) {
|
|
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, rowMap)
|
|
resultIdMap = rowMap
|
|
}
|
|
}
|
|
} else {
|
|
nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel })
|
|
const rk = rekeyFolderSubtreeAt(nextTree, id, prev.labelRows.map((r) => r.id))
|
|
const idMap = rk?.idMap ?? {}
|
|
if (rk && Object.keys(idMap).length > 0) {
|
|
nextTree = rk.tree
|
|
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, idMap)
|
|
resultIdMap = idMap
|
|
}
|
|
}
|
|
return { folderTree: nextTree, labelRows: nextRows, navItemPrefs }
|
|
})
|
|
|
|
return { idMap: resultIdMap, emailRename }
|
|
},
|
|
|
|
removeFolderOrLabelRow: (id) => {
|
|
if (isSystemNavLabelId(id)) return []
|
|
const snap = get()
|
|
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
|
|
const row = snap.labelRows.find((r) => r.id === id)
|
|
let labelsToRemove: string[] = []
|
|
if (row) {
|
|
const lab = snapMap[id]
|
|
if (lab) labelsToRemove = [lab]
|
|
} else {
|
|
const extracted = extractNode(snap.folderTree, id)
|
|
if (extracted.extracted) {
|
|
const uniq = new Set<string>()
|
|
const collectLabs = (n: FolderTreeNode) => {
|
|
uniq.add(n.label)
|
|
n.children?.forEach(collectLabs)
|
|
}
|
|
collectLabs(extracted.extracted)
|
|
labelsToRemove = [...uniq]
|
|
}
|
|
}
|
|
|
|
set((prev) => {
|
|
const rowInner = prev.labelRows.find((r) => r.id === id)
|
|
if (rowInner) {
|
|
return {
|
|
labelRows: prev.labelRows.filter((r) => r.id !== id),
|
|
navItemPrefs: Object.fromEntries(
|
|
Object.entries(prev.navItemPrefs).filter(([k]) => k !== id)
|
|
),
|
|
}
|
|
}
|
|
const extracted = extractNode(prev.folderTree, id)
|
|
if (extracted.extracted) {
|
|
const dropIds = collectSubtreeIds(extracted.extracted)
|
|
return {
|
|
folderTree: extracted.next,
|
|
navItemPrefs: Object.fromEntries(
|
|
Object.entries(prev.navItemPrefs).filter(([k]) => !dropIds.has(k))
|
|
),
|
|
}
|
|
}
|
|
return prev
|
|
})
|
|
|
|
return labelsToRemove
|
|
},
|
|
|
|
moveFolder: (id, newParentId) => {
|
|
let resultIdMap: Record<string, string> = {}
|
|
set((prev) => {
|
|
if (newParentId !== null) {
|
|
if (newParentId === id) return prev
|
|
if (isDescendantOf(prev.folderTree, newParentId, id)) return prev
|
|
}
|
|
const ex = extractNode(prev.folderTree, id)
|
|
if (!ex.extracted) return prev
|
|
let tree = ex.next
|
|
tree = insertFolderChild(tree, newParentId, ex.extracted)
|
|
const movedOldId = ex.extracted.id
|
|
const rekeyed = applyFolderTreeRekey(
|
|
tree,
|
|
movedOldId,
|
|
prev.labelRows.map((r) => r.id),
|
|
prev.navItemPrefs
|
|
)
|
|
tree = rekeyed.tree
|
|
resultIdMap = rekeyed.idMap
|
|
return { folderTree: tree, navItemPrefs: rekeyed.navItemPrefs }
|
|
})
|
|
return resultIdMap
|
|
},
|
|
|
|
reorderLabelRows: (draggedId, targetId, placement) => {
|
|
if (draggedId === targetId) return
|
|
if (isSystemNavLabelId(draggedId) || isSystemNavLabelId(targetId)) return
|
|
set((prev) => {
|
|
const from = prev.labelRows.findIndex((r) => r.id === draggedId)
|
|
const targetIdx = prev.labelRows.findIndex((r) => r.id === targetId)
|
|
if (from < 0 || targetIdx < 0) return prev
|
|
let toIndex = placement === "before" ? targetIdx : targetIdx + 1
|
|
if (from < toIndex) toIndex -= 1
|
|
const next = [...prev.labelRows]
|
|
const [item] = next.splice(from, 1)
|
|
next.splice(toIndex, 0, item)
|
|
return { labelRows: next }
|
|
})
|
|
},
|
|
|
|
moveFolderRelative: (draggedId, targetId, placement) => {
|
|
let resultIdMap: Record<string, string> = {}
|
|
set((prev) => {
|
|
if (draggedId === targetId) return prev
|
|
if (isDescendantOf(prev.folderTree, targetId, draggedId)) return prev
|
|
|
|
const draggedLoc = findNodeLocation(prev.folderTree, draggedId)
|
|
const targetLoc = findNodeLocation(prev.folderTree, targetId)
|
|
if (!targetLoc) return prev
|
|
|
|
const ex = extractNode(prev.folderTree, draggedId)
|
|
if (!ex.extracted) return prev
|
|
|
|
let tree = ex.next
|
|
let navItemPrefs = prev.navItemPrefs
|
|
|
|
if (placement === "inside") {
|
|
if (draggedId === targetId) return prev
|
|
tree = insertFolderChild(tree, targetId, ex.extracted)
|
|
} else {
|
|
let insertIndex =
|
|
placement === "before" ? targetLoc.index : targetLoc.index + 1
|
|
if (
|
|
draggedLoc &&
|
|
draggedLoc.parentId === targetLoc.parentId &&
|
|
draggedLoc.index < insertIndex
|
|
) {
|
|
insertIndex -= 1
|
|
}
|
|
tree = insertNodeAtParentIndex(
|
|
tree,
|
|
targetLoc.parentId,
|
|
insertIndex,
|
|
ex.extracted
|
|
)
|
|
}
|
|
|
|
const rekeyed = applyFolderTreeRekey(
|
|
tree,
|
|
ex.extracted.id,
|
|
prev.labelRows.map((r) => r.id),
|
|
navItemPrefs
|
|
)
|
|
tree = rekeyed.tree
|
|
navItemPrefs = rekeyed.navItemPrefs
|
|
resultIdMap = rekeyed.idMap
|
|
return { folderTree: tree, navItemPrefs }
|
|
})
|
|
return resultIdMap
|
|
},
|
|
|
|
addChildLabelRow: (parentLabelRowId, childName) => {
|
|
const sub = childName.trim()
|
|
if (!sub) return
|
|
set((s) => {
|
|
const parent = s.labelRows.find((r) => r.id === parentLabelRowId)
|
|
if (!parent) return s
|
|
const combined = `${parent.label}/${sub}`
|
|
const known = allLabelTextsLower(s.folderTree, s.labelRows)
|
|
if (known.has(combined.toLowerCase())) return s
|
|
const ids = new Set(s.labelRows.map((r) => r.id))
|
|
const nid = uniqueLabelRowId(newLabelRowId(combined), ids)
|
|
return {
|
|
labelRows: [
|
|
...s.labelRows,
|
|
normalizeLabelRow({
|
|
id: nid,
|
|
label: combined,
|
|
color: parent.color ?? "bg-gray-500",
|
|
tabbed: false,
|
|
favorite: false,
|
|
excludeFromPrincipal: false,
|
|
showInMessageList: true,
|
|
enabled: true,
|
|
}),
|
|
],
|
|
}
|
|
})
|
|
},
|
|
|
|
setLabelRowEnabled: (id, enabled) =>
|
|
set((s) => ({
|
|
labelRows: s.labelRows.map((r) =>
|
|
r.id === id ? normalizeLabelRow({ ...r, enabled }) : r
|
|
),
|
|
})),
|
|
|
|
hydrateFolderTreeFromApi: (tree) => set({ folderTree: tree }),
|
|
|
|
hydrateLabelRowsFromApi: (rows) =>
|
|
set((s) => {
|
|
const systemRows = s.labelRows.filter((r) => isSystemNavLabelId(r.id))
|
|
const apiIds = new Set(rows.map((r) => r.id))
|
|
const mergedSystem = systemRows.filter((r) => !apiIds.has(r.id))
|
|
return {
|
|
labelRows: [
|
|
...mergedSystem,
|
|
...rows.map((r) => normalizeLabelRow(r)),
|
|
],
|
|
}
|
|
}),
|
|
|
|
getFolderIdToLabel: () => {
|
|
const s = get()
|
|
return buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
|
|
},
|
|
|
|
getEmailLabelToSidebarFolderId: () => {
|
|
const s = get()
|
|
const fToL = buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
|
|
return buildEmailLabelToSidebarFolderId(fToL)
|
|
},
|
|
|
|
getNavItemPrefs: (id) => {
|
|
const p = get().navItemPrefs[id]
|
|
return { sidebar: p?.sidebar ?? "show", messages: p?.messages ?? "show" }
|
|
},
|
|
}),
|
|
{
|
|
name: "ultimail-nav-state",
|
|
storage: debouncedPersistJSONStorage,
|
|
version: 2,
|
|
migrate: (persisted, fromVersion) => {
|
|
if (fromVersion < 2 && persisted && typeof persisted === "object") {
|
|
const p = persisted as { labelRows?: LabelRowItem[] }
|
|
if (Array.isArray(p.labelRows)) {
|
|
return {
|
|
...persisted,
|
|
labelRows: reconcileLabelRowsFromPersisted(p.labelRows),
|
|
}
|
|
}
|
|
}
|
|
return persisted
|
|
},
|
|
}
|
|
)
|
|
)
|