From 25046e85e38edf5057a87fff0e34c81c72ed0bab Mon Sep 17 00:00:00 2001 From: John Ellis Date: Tue, 25 Aug 2026 12:54:33 -0700 Subject: [PATCH] Give a folder a colour from its right-click menu Right-click a folder and pick one of the twelve colours the calendar already uses, or clear it again. The colour tints the folder's icon; the label keeps the sidebar's own contrast, which a dozen arbitrary colours would not reliably give it. Kept by mailbox id rather than by name, so a folder renamed or dragged somewhere else keeps its colour. Stored in settings, which live in this browser -- JMAP has nowhere on a Mailbox to put a colour, and every other colour in the app, labels and event categories included, already works this way. Worth knowing it does not follow you to another device. The cascade needed care: .nav-item svg sets the colour on the icon itself, so a colour inherited from a wrapper does nothing. Checking getComputedStyle on the wrapper said the icon was purple while the pixels stayed grey; the rule now targets the svg, and the check now reads the pixels. --- web/src/lib/__tests__/folderMove.test.ts | 16 ++++++++++- web/src/lib/folderMove.ts | 5 ++++ web/src/store/settings.ts | 6 ++++ web/src/styles/app.css | 5 ++++ web/src/views/mail/MailboxTree.tsx | 36 +++++++++++++++++++++--- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/web/src/lib/__tests__/folderMove.test.ts b/web/src/lib/__tests__/folderMove.test.ts index 14d1585..b679688 100644 --- a/web/src/lib/__tests__/folderMove.test.ts +++ b/web/src/lib/__tests__/folderMove.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canDropFolder, descendantIds, movable } from "../folderMove"; +import { canDropFolder, descendantIds, folderColor, movable } from "../folderMove"; import type { Id, Mailbox } from "@/jmap/types"; const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox => @@ -67,3 +67,17 @@ describe("canDropFolder", () => { expect(canDropFolder(tree, "gone", "work")).toBe(false); }); }); + +describe("folderColor", () => { + it("returns the colour chosen for that folder, and null for the rest", () => { + const colors = { work: "#7c3aed" }; + expect(folderColor(colors, "work")).toBe("#7c3aed"); + expect(folderColor(colors, "news")).toBeNull(); + expect(folderColor({}, "work")).toBeNull(); + }); + + it("is keyed by id, so a renamed folder keeps its colour", () => { + // The id is stable across a rename; the name and path are not. + expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e"); + }); +}); diff --git a/web/src/lib/folderMove.ts b/web/src/lib/folderMove.ts index f5e81b7..9b42605 100644 --- a/web/src/lib/folderMove.ts +++ b/web/src/lib/folderMove.ts @@ -45,3 +45,8 @@ export function canDropFolder(mailboxes: Record, draggedId: Id, tar if (!mailboxes[targetId]) return false; return !descendantIds(mailboxes, draggedId).has(targetId); } + +/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */ +export function folderColor(colors: Record, id: Id): string | null { + return colors[id] ?? null; +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 125da43..8bd8e5d 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -62,6 +62,11 @@ export interface Settings { fontSize: "small" | "medium" | "large"; templates: Template[]; labels: Array<{ keyword: string; name: string; color: string }>; + /** + * Folder colours, by mailbox id. Local to this browser, like every other + * colour here: JMAP has nowhere on a Mailbox to keep one. + */ + folderColors: Record; sidebarCollapsed: boolean; showHiddenFolders: boolean; trustedImageSenders: string[]; @@ -116,6 +121,7 @@ export const DEFAULT_SETTINGS: Settings = { fontSize: "medium", templates: [], labels: [], + folderColors: {}, sidebarCollapsed: false, showHiddenFolders: false, trustedImageSenders: [], diff --git a/web/src/styles/app.css b/web/src/styles/app.css index d380190..75de100 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -324,6 +324,11 @@ img { max-width: 100%; } .nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; } .nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; } .nav-item.folder-row.dragging { opacity: .45; } +/* A folder colour tints its icon; the label keeps the sidebar's contrast. */ +.folder-row .folder-icon { display: inline-flex; align-items: center; } +/* .nav-item svg sets colour on the svg itself, so inheriting from the span is + not enough -- the icon has to be targeted directly to win the cascade. */ +.folder-row .folder-icon[style*="--folder-color"] svg { color: var(--folder-color); } /* The Folders heading doubles as the way back to the top level while dragging. */ .nav-section.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); color: var(--accent-soft-fg); } .nav-item svg { flex: 0 0 auto; color: var(--fg-muted); } diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index bf0119a..e6d8192 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -1,16 +1,17 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react"; +import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react"; import { useMail } from "@/store/mail"; import { isScheduledMailbox } from "@/store/scheduled"; import { useSettings } from "@/store/settings"; import type { Id, Mailbox } from "@/jmap/types"; -import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; +import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; +import { CALENDAR_COLORS } from "@/ui/misc"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; import { loadRaw, saveJson } from "@/lib/storage"; -import { canDropFolder, movable } from "@/lib/folderMove"; +import { canDropFolder, folderColor, movable } from "@/lib/folderMove"; const ROLE_ICONS: Record = { inbox: , @@ -199,6 +200,10 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, // Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold). const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled; const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? : ; + // A chosen colour tints the icon only; the label keeps the tree's own + // contrast, which a dozen arbitrary colours would not reliably give it. + // Subscribed, not read once: picking a colour has to repaint the row. + const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id)); const onDragOver = (e: DragEvent) => { const folder = e.dataTransfer.types.includes(FOLDER_MIME); @@ -266,7 +271,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, > {hasChildren ? open ? : : null} - {icon} + {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} {count > 0 && } @@ -287,6 +292,8 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) { const [, navigate] = useLocation(); + const colors = useSettings((s) => s.settings.folderColors); + const update = useSettings((s) => s.update); const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id)); const subUnread = useMail((s) => { const all = Object.values(s.mailboxes); @@ -326,6 +333,13 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; if (ok) await useMail.getState().emptyMailbox(m.id); }; const isSpecial = Boolean(m.role) && m.role !== "subscribed"; + const color = folderColor(colors, m.id); + const setColor = (c: string | null) => { + const next = { ...colors }; + if (c) next[m.id] = c; + else delete next[m.id]; + update({ folderColors: next }); + }; return ( <> } label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} /> @@ -343,6 +357,20 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; : } label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> } label="Share…" onClick={onShare} /> + Colour +
+ {CALENDAR_COLORS.map((c) => ( +
+ {color && } label="Use the default colour" onClick={() => setColor(null)} />} + {m.role === "trash" && } label="Empty folder" onClick={() => void empty()} danger />} } label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />