ultisuite-client/lib/stores/nav-store.ts
2026-05-16 20:30:50 +02:00

591 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">>
}
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
),
})),
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
},
}
)
)