Merge pull request #204 from Coffey-Labs/feat/nested-labels
Nest labels, and let each one say how prominent it is
This commit is contained in:
+19
@@ -235,6 +235,25 @@ and they survive ihasmail entirely. A message can carry any number. They are
|
|||||||
managed in Settings › Labels, applied from `l` or the context menu, and
|
managed in Settings › Labels, applied from `l` or the context menu, and
|
||||||
optionally listed in the sidebar.
|
optionally listed in the sidebar.
|
||||||
|
|
||||||
|
- **Nesting.** A label can sit under another, and the sidebar indents it.
|
||||||
|
Nesting is **display only** — the keywords stay flat on the message, so
|
||||||
|
moving a label under another rewrites nothing in the mailbox and a client
|
||||||
|
that knows nothing about ihasmail sees exactly what it always did. The parent
|
||||||
|
picker will not offer a label's own descendants, so a loop cannot be built;
|
||||||
|
and because settings sync between devices, a label whose parent was deleted
|
||||||
|
elsewhere comes back to the top level rather than disappearing, while a cycle
|
||||||
|
arriving from an older device is broken rather than hung on.
|
||||||
|
- **How prominent each one is**: always in the sidebar, only while it has
|
||||||
|
unread mail, or never. "Only when unread" is the useful one — something filed
|
||||||
|
two years ago should not hold a row for ever.
|
||||||
|
- A label kept by that rule **keeps its ancestors**, whatever they were set to.
|
||||||
|
A child cannot be drawn under a parent that is not there, and promoting it to
|
||||||
|
the top level would silently rearrange the tree at the moment the reader is
|
||||||
|
least able to explain why. The parent comes back as a container instead.
|
||||||
|
- **Unread counts** sit beside each label, fetched for every label in a single
|
||||||
|
request rather than one apiece, and refreshed on the same beat as the folder
|
||||||
|
counts — the things that move them are the same things.
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
The query runs on the **server**, over the whole mailbox, not over the part the
|
The query runs on the **server**, over the whole mailbox, not over the part the
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/labelTree";
|
||||||
|
import type { Label } from "@/store/settings";
|
||||||
|
|
||||||
|
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
|
||||||
|
|
||||||
|
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
|
||||||
|
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
|
||||||
|
|
||||||
|
describe("labelTree", () => {
|
||||||
|
it("nests a label under its parent and indents it", () => {
|
||||||
|
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
|
||||||
|
expect(roots).toHaveLength(1);
|
||||||
|
expect(roots[0]!.label.keyword).toBe("work");
|
||||||
|
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
|
||||||
|
expect(roots[0]!.children[0]!.depth).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests three deep", () => {
|
||||||
|
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a label back at the top when its parent no longer exists", () => {
|
||||||
|
// Settings sync between devices; a parent can be deleted on one while
|
||||||
|
// another still points at it. Dropping the child would lose it for good.
|
||||||
|
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a cycle rather than hanging", () => {
|
||||||
|
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a label parented to itself", () => {
|
||||||
|
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries each label's own unread count, not its children's", () => {
|
||||||
|
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
|
||||||
|
expect(roots[0]!.unread).toBe(2);
|
||||||
|
expect(roots[0]!.children[0]!.unread).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visibleLabels", () => {
|
||||||
|
it("draws everything set to always", () => {
|
||||||
|
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never draws a hidden label", () => {
|
||||||
|
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws an unread-only label just while it has unread mail", () => {
|
||||||
|
const labels = [L("a", { visibility: "unread" })];
|
||||||
|
expect(flat(labels, { a: 0 })).toEqual([]);
|
||||||
|
expect(flat(labels, { a: 1 })).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
|
||||||
|
// A child cannot be drawn under a parent that is not there, and promoting
|
||||||
|
// it would silently rearrange the tree. The parent comes back as a
|
||||||
|
// container instead.
|
||||||
|
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
|
||||||
|
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a hidden parent too, when a child survives", () => {
|
||||||
|
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
|
||||||
|
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a whole branch when nothing in it survives", () => {
|
||||||
|
const labels = [
|
||||||
|
L("work", { visibility: "unread" }),
|
||||||
|
L("work_urgent", { parent: "work", visibility: "unread" }),
|
||||||
|
L("other"),
|
||||||
|
];
|
||||||
|
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a grandparent when only a grandchild survives", () => {
|
||||||
|
const labels = [
|
||||||
|
L("a", { visibility: "hidden" }),
|
||||||
|
L("b", { parent: "a", visibility: "hidden" }),
|
||||||
|
L("c", { parent: "b" }),
|
||||||
|
];
|
||||||
|
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
|
||||||
|
const l = L("a");
|
||||||
|
expect(l.visibility).toBeUndefined();
|
||||||
|
expect(flat([l], {})).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("descendantKeywords", () => {
|
||||||
|
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
|
||||||
|
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
|
||||||
|
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
|
||||||
|
expect([...descendantKeywords(roots, "d")]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a label that is not there", () => {
|
||||||
|
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Labels arranged into a tree, and which of them the sidebar draws.
|
||||||
|
*
|
||||||
|
* **Nesting is display only.** The keywords stay flat on the message, which is
|
||||||
|
* what keeps them readable by every other client — moving a label under
|
||||||
|
* another rewrites nothing in the mailbox, and a client that knows nothing
|
||||||
|
* about ihasmail still sees the same keywords it always did.
|
||||||
|
*/
|
||||||
|
import type { Label, LabelVisibility } from "@/store/settings";
|
||||||
|
|
||||||
|
export interface LabelNode {
|
||||||
|
label: Label;
|
||||||
|
/** 0 at the top; only ever used to indent. */
|
||||||
|
depth: number;
|
||||||
|
children: LabelNode[];
|
||||||
|
/** Unread messages carrying this keyword. Its own, not its children's. */
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityOf = (l: Label): LabelVisibility => l.visibility ?? "always";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the tree.
|
||||||
|
*
|
||||||
|
* Two malformed shapes have to survive, because settings sync between devices
|
||||||
|
* and a label can be deleted on one while another is still pointing at it:
|
||||||
|
*
|
||||||
|
* - **A parent that no longer exists** puts its child back at the top level
|
||||||
|
* rather than dropping it. A label that vanishes from the sidebar because
|
||||||
|
* something else was deleted is a label the reader cannot get back.
|
||||||
|
* - **A cycle** — a under b, b under a — is broken by treating the first
|
||||||
|
* label that closes the loop as a root. Nothing is lost and nothing hangs.
|
||||||
|
*/
|
||||||
|
export function labelTree(labels: Label[], counts: Record<string, number> = {}): LabelNode[] {
|
||||||
|
const byKeyword = new Map<string, Label>();
|
||||||
|
for (const l of labels) byKeyword.set(l.keyword, l);
|
||||||
|
|
||||||
|
/** Whether following `parent` from here reaches a real root without looping. */
|
||||||
|
const rooted = (l: Label): boolean => {
|
||||||
|
const seen = new Set<string>([l.keyword]);
|
||||||
|
let cur = l.parent ? byKeyword.get(l.parent) : undefined;
|
||||||
|
while (cur) {
|
||||||
|
if (seen.has(cur.keyword)) return false;
|
||||||
|
seen.add(cur.keyword);
|
||||||
|
cur = cur.parent ? byKeyword.get(cur.parent) : undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = new Map<string, LabelNode>();
|
||||||
|
for (const l of labels) nodes.set(l.keyword, { label: l, depth: 0, children: [], unread: counts[l.keyword] ?? 0 });
|
||||||
|
|
||||||
|
const roots: LabelNode[] = [];
|
||||||
|
for (const l of labels) {
|
||||||
|
const node = nodes.get(l.keyword)!;
|
||||||
|
const parent = l.parent && l.parent !== l.keyword && rooted(l) ? nodes.get(l.parent) : undefined;
|
||||||
|
if (parent) parent.children.push(node);
|
||||||
|
else roots.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDepth = (n: LabelNode, depth: number) => {
|
||||||
|
n.depth = depth;
|
||||||
|
for (const c of n.children) setDepth(c, depth + 1);
|
||||||
|
};
|
||||||
|
for (const r of roots) setDepth(r, 0);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nodes the sidebar draws, flattened in the order they appear.
|
||||||
|
*
|
||||||
|
* `hidden` removes a label outright. `unread` shows it only while it has
|
||||||
|
* unread mail — which is the point of it: a label you filed something under
|
||||||
|
* two years ago should not take up a row for ever.
|
||||||
|
*
|
||||||
|
* **A label kept by the rule keeps its ancestors, whatever they said.** A
|
||||||
|
* child cannot be drawn under a parent that is not there; the alternative is
|
||||||
|
* promoting it to the top level, which silently rearranges the tree at the
|
||||||
|
* moment the reader is least able to explain why. The parent comes back as a
|
||||||
|
* container, and its own count still says whether it has anything of its own.
|
||||||
|
*/
|
||||||
|
export function visibleLabels(roots: LabelNode[]): LabelNode[] {
|
||||||
|
const keep = new Set<LabelNode>();
|
||||||
|
|
||||||
|
const walk = (n: LabelNode): boolean => {
|
||||||
|
// Depth-first: a node's fate depends on its descendants, not the reverse.
|
||||||
|
let keptChild = false;
|
||||||
|
for (const c of n.children) keptChild = walk(c) || keptChild;
|
||||||
|
|
||||||
|
const v = visibilityOf(n.label);
|
||||||
|
const self = v === "always" || (v === "unread" && n.unread > 0);
|
||||||
|
if (self || keptChild) {
|
||||||
|
keep.add(n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const r of roots) walk(r);
|
||||||
|
|
||||||
|
const out: LabelNode[] = [];
|
||||||
|
const emit = (n: LabelNode) => {
|
||||||
|
if (!keep.has(n)) return;
|
||||||
|
out.push(n);
|
||||||
|
for (const c of n.children) emit(c);
|
||||||
|
};
|
||||||
|
for (const r of roots) emit(r);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keywords that would become unreachable if `keyword` were re-parented under
|
||||||
|
* `candidate` — used to keep the parent picker from offering a cycle.
|
||||||
|
*/
|
||||||
|
export function descendantKeywords(roots: LabelNode[], keyword: string): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const find = (n: LabelNode): LabelNode | null => {
|
||||||
|
if (n.label.keyword === keyword) return n;
|
||||||
|
for (const c of n.children) {
|
||||||
|
const hit = find(c);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let node: LabelNode | null = null;
|
||||||
|
for (const r of roots) {
|
||||||
|
node = find(r);
|
||||||
|
if (node) break;
|
||||||
|
}
|
||||||
|
if (!node) return out;
|
||||||
|
const collect = (n: LabelNode) => {
|
||||||
|
for (const c of n.children) {
|
||||||
|
out.add(c.label.keyword);
|
||||||
|
collect(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(node);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+49
-1
@@ -19,6 +19,7 @@ import type {
|
|||||||
Thread,
|
Thread,
|
||||||
VacationResponse,
|
VacationResponse,
|
||||||
ChangesResponse,
|
ChangesResponse,
|
||||||
|
Invocation,
|
||||||
} from "@/jmap/types";
|
} from "@/jmap/types";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { settings, useSettings } from "./settings";
|
import { settings, useSettings } from "./settings";
|
||||||
@@ -130,6 +131,8 @@ export interface MailState {
|
|||||||
vacation: VacationResponse | null;
|
vacation: VacationResponse | null;
|
||||||
list: ListState | null;
|
list: ListState | null;
|
||||||
selected: Record<Id, true>;
|
selected: Record<Id, true>;
|
||||||
|
/** Unread messages per label keyword, for the sidebar. */
|
||||||
|
labelCounts: Record<string, number>;
|
||||||
/**
|
/**
|
||||||
* The selection means "everything the current query matches", not the rows
|
* The selection means "everything the current query matches", not the rows
|
||||||
* that happen to be loaded. Ticking the header box selects the loaded page;
|
* that happen to be loaded. Ticking the header box selects the loaded page;
|
||||||
@@ -190,6 +193,8 @@ export interface MailState {
|
|||||||
|
|
||||||
select(ids: Id[], on: boolean): void;
|
select(ids: Id[], on: boolean): void;
|
||||||
clearSelection(): void;
|
clearSelection(): void;
|
||||||
|
/** Refresh the per-label unread counts, in one request. */
|
||||||
|
loadLabelCounts(): Promise<void>;
|
||||||
selectAll(): void;
|
selectAll(): void;
|
||||||
/** Extend the selection from the loaded rows to everything the query matches. */
|
/** Extend the selection from the loaded rows to everything the query matches. */
|
||||||
selectAllMatching(): void;
|
selectAllMatching(): void;
|
||||||
@@ -221,6 +226,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
vacation: null,
|
vacation: null,
|
||||||
list: null,
|
list: null,
|
||||||
selected: {},
|
selected: {},
|
||||||
|
labelCounts: {},
|
||||||
selectedAll: false,
|
selectedAll: false,
|
||||||
anchorId: null,
|
anchorId: null,
|
||||||
loadingThreads: {},
|
loadingThreads: {},
|
||||||
@@ -260,6 +266,11 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
const mailboxes: Record<Id, Mailbox> = {};
|
const mailboxes: Record<Id, Mailbox> = {};
|
||||||
for (const m of res.list) mailboxes[m.id] = m;
|
for (const m of res.list) mailboxes[m.id] = m;
|
||||||
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
|
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
|
||||||
|
// Label counts move for the same reasons folder counts do -- something was
|
||||||
|
// read, moved or deleted -- so they are refreshed on the same beat rather
|
||||||
|
// than on a timer of their own. Not awaited: the folder tree should not
|
||||||
|
// wait on decoration.
|
||||||
|
void get().loadLabelCounts();
|
||||||
},
|
},
|
||||||
|
|
||||||
roleId(role) {
|
roleId(role) {
|
||||||
@@ -299,7 +310,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
|
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
|
||||||
selected: {},
|
selected: {},
|
||||||
selectedAll: false,
|
selectedAll: false,
|
||||||
anchorId: null,
|
anchorId: null,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -919,6 +930,43 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
return { selected: next };
|
return { selected: next };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async loadLabelCounts() {
|
||||||
|
const accountId = get().accountId;
|
||||||
|
const labels = settings().labels;
|
||||||
|
if (!accountId || !labels.length) {
|
||||||
|
if (Object.keys(get().labelCounts).length) set({ labelCounts: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* One request carrying a query per label, rather than a request each. The
|
||||||
|
* count is the whole answer, so `limit: 0` keeps the server from sending
|
||||||
|
* ids that would only be thrown away -- what is wanted is `total`.
|
||||||
|
*/
|
||||||
|
const calls: Invocation[] = labels.map((l, i) => [
|
||||||
|
"Email/query",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
filter: { operator: "AND", conditions: [{ hasKeyword: l.keyword }, { notKeyword: "$seen" }] },
|
||||||
|
limit: 0,
|
||||||
|
calculateTotal: true,
|
||||||
|
},
|
||||||
|
`c${i}`,
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const res = await client.request(calls);
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const [, result, id] of res.methodResponses) {
|
||||||
|
const label = labels[Number(String(id).slice(1))];
|
||||||
|
if (!label) continue;
|
||||||
|
counts[label.keyword] = (result as { total?: number }).total ?? 0;
|
||||||
|
}
|
||||||
|
set({ labelCounts: counts });
|
||||||
|
} catch {
|
||||||
|
// A count is decoration. Failing to get one is not worth a toast, and
|
||||||
|
// the sidebar falls back to drawing the label without a number.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
set({ selected: {}, selectedAll: false });
|
set({ selected: {}, selectedAll: false });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ export type ImagePolicy = "ask" | "always" | "contacts";
|
|||||||
export type ComposeFormat = "html" | "text";
|
export type ComposeFormat = "html" | "text";
|
||||||
export type ReadReceiptPolicy = "ask" | "never";
|
export type ReadReceiptPolicy = "ask" | "never";
|
||||||
|
|
||||||
|
/** How prominent a label is in the sidebar. */
|
||||||
|
export type LabelVisibility = "always" | "unread" | "hidden";
|
||||||
|
|
||||||
|
export interface Label {
|
||||||
|
/** The IMAP keyword itself, which is what actually rides on the message. */
|
||||||
|
keyword: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
/**
|
||||||
|
* The keyword of the label this one sits under, if any.
|
||||||
|
*
|
||||||
|
* Nesting is display only. The keywords stay flat on the message, which is
|
||||||
|
* what keeps them readable by every other client -- a label moved under
|
||||||
|
* another one does not rewrite anything in the mailbox.
|
||||||
|
*/
|
||||||
|
parent?: string;
|
||||||
|
/** Absent means "always", so a settings file written before this parses unchanged. */
|
||||||
|
visibility?: LabelVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -117,7 +137,7 @@ export interface Settings {
|
|||||||
labelsSidebar: boolean;
|
labelsSidebar: boolean;
|
||||||
fontSize: "small" | "medium" | "large";
|
fontSize: "small" | "medium" | "large";
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
labels: Array<{ keyword: string; name: string; color: string }>;
|
labels: Label[];
|
||||||
/**
|
/**
|
||||||
* Folder colours, by mailbox id. Local to this browser, like every other
|
* Folder colours, by mailbox id. Local to this browser, like every other
|
||||||
* colour here: JMAP has nowhere on a Mailbox to keep one.
|
* colour here: JMAP has nowhere on a Mailbox to keep one.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useLocation } from "wouter";
|
|||||||
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, 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 { AlertOctagon, Archive, ChevronDown, ChevronLeft, 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 { useMail } from "@/store/mail";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
|
import { labelTree, visibleLabels } from "@/lib/labelTree";
|
||||||
import { isScheduledMailbox } from "@/store/scheduled";
|
import { isScheduledMailbox } from "@/store/scheduled";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
@@ -40,6 +41,8 @@ export function MailboxTree() {
|
|||||||
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
|
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
|
||||||
|
const labelCounts = useMail((s) => s.labelCounts);
|
||||||
|
const shownLabels = useMemo(() => visibleLabels(labelTree(labels, labelCounts)), [labels, labelCounts]);
|
||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||||||
@@ -225,7 +228,7 @@ export function MailboxTree() {
|
|||||||
))}
|
))}
|
||||||
{/* Labels are a flat list that belongs to the mailbox, not to whichever
|
{/* Labels are a flat list that belongs to the mailbox, not to whichever
|
||||||
folder is on screen, so they stay at the top level of the drill. */}
|
folder is on screen, so they stay at the top level of the drill. */}
|
||||||
{!drill && labelsSidebar && labels.length > 0 && (
|
{!drill && labelsSidebar && shownLabels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span>{t("Labels")}</span>
|
<span>{t("Labels")}</span>
|
||||||
@@ -233,10 +236,19 @@ export function MailboxTree() {
|
|||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{labels.map((l) => (
|
{shownLabels.map((n) => (
|
||||||
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item folder-row" title={l.name}>
|
<Link
|
||||||
<span className="nav-label-color" style={{ "--label-color": l.color } as React.CSSProperties} />
|
key={n.label.keyword}
|
||||||
<span className="nav-label">{l.name}</span>
|
href={`/search?q=label:${encodeURIComponent(n.label.keyword)}`}
|
||||||
|
className="nav-item folder-row"
|
||||||
|
title={n.label.name}
|
||||||
|
/* Indented rather than nested in the DOM: the rows are a flat
|
||||||
|
list of links and a nested one would break keyboard order. */
|
||||||
|
style={{ paddingLeft: 12 + n.depth * 14 }}
|
||||||
|
>
|
||||||
|
<span className="nav-label-color" style={{ "--label-color": n.label.color } as React.CSSProperties} />
|
||||||
|
<span className="nav-label">{n.label.name}</span>
|
||||||
|
{n.unread > 0 && <span className="nav-count">{n.unread}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings, type LabelVisibility } from "@/store/settings";
|
||||||
|
import { labelTree, descendantKeywords } from "@/lib/labelTree";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||||
import { promptDialog } from "@/ui/dialog";
|
import { promptDialog } from "@/ui/dialog";
|
||||||
import { t, tNode } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
@@ -9,6 +11,8 @@ export function LabelsSettings() {
|
|||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const roots = useMemo(() => labelTree(labels), [labels]);
|
||||||
|
const descendantsOf = (keyword: string) => descendantKeywords(roots, keyword);
|
||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
|
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
|
||||||
@@ -21,7 +25,7 @@ export function LabelsSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("Labels")}</h1>
|
<h1>{t("Labels")}</h1>
|
||||||
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.")}</p>
|
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colours and nesting are ihasmail\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.")}</p>
|
||||||
{labels.map((l) => (
|
{labels.map((l) => (
|
||||||
<div key={l.keyword} className="card">
|
<div key={l.keyword} className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
@@ -36,6 +40,37 @@ export function LabelsSettings() {
|
|||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field-row" style={{ marginTop: 10 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Nested under")}</label>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={l.parent ?? ""}
|
||||||
|
onChange={(e) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, parent: e.target.value || undefined } : x)) })}
|
||||||
|
>
|
||||||
|
<option value="">{t("Nothing (top level)")}</option>
|
||||||
|
{/* Itself and anything already beneath it are left out, so the
|
||||||
|
picker cannot be used to build a loop. */}
|
||||||
|
{labels
|
||||||
|
.filter((c) => c.keyword !== l.keyword && !descendantsOf(l.keyword).has(c.keyword))
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.keyword} value={c.keyword}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Show in the sidebar")}</label>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={l.visibility ?? "always"}
|
||||||
|
onChange={(e) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, visibility: e.target.value as LabelVisibility } : x)) })}
|
||||||
|
>
|
||||||
|
<option value="always">{t("Always")}</option>
|
||||||
|
<option value="unread">{t("Only when it has unread mail")}</option>
|
||||||
|
<option value="hidden">{t("Never")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
|
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user