Nest labels, and let each one say how prominent it is
A flat list is fine at five labels and unreadable at thirty, and there was no way to keep one that matters occasionally without it holding a row for ever. A label can now sit under another, and each says whether it belongs in the sidebar always, only while it has unread mail, or never. 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 sees exactly what it always did. Both new fields are optional, so a settings file written before this parses unchanged and means what it did. Settings sync between devices, so the tree has to survive shapes that should not exist. A label whose parent was deleted on another device comes back to the top level rather than vanishing -- a label that disappears because something else was deleted is one the reader cannot get back. A cycle arriving from an older device is broken by treating the label that closes the loop as a root, so nothing is lost and nothing hangs. The parent picker will not offer a label's own descendants, so one cannot be built here in the first place. A label kept by the unread 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, and its own count still says whether it has anything of its own. Unread counts come from one request carrying a query per label rather than a request each, with limit 0 so the server does not send ids that would only be thrown away. They refresh on the same beat as the folder counts, since the things that move them are the same things, and a failure is swallowed: a count is decoration, and the sidebar draws the label without one. Also corrects the Labels page, which said names and colours are kept in this browser. They live in the account's own Files and follow it between devices, like every other setting that is not about this screen.
This commit is contained in:
@@ -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 { useMail } from "@/store/mail";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { labelTree, visibleLabels } from "@/lib/labelTree";
|
||||
import { isScheduledMailbox } from "@/store/scheduled";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
@@ -40,6 +41,8 @@ export function MailboxTree() {
|
||||
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
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 [menuTarget, setMenuTarget] = 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
|
||||
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">
|
||||
<span>{t("Labels")}</span>
|
||||
@@ -233,10 +236,19 @@ export function MailboxTree() {
|
||||
<Pencil size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
{labels.map((l) => (
|
||||
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item folder-row" title={l.name}>
|
||||
<span className="nav-label-color" style={{ "--label-color": l.color } as React.CSSProperties} />
|
||||
<span className="nav-label">{l.name}</span>
|
||||
{shownLabels.map((n) => (
|
||||
<Link
|
||||
key={n.label.keyword}
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "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 { promptDialog } from "@/ui/dialog";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
@@ -9,6 +11,8 @@ export function LabelsSettings() {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const roots = useMemo(() => labelTree(labels), [labels]);
|
||||
const descendantsOf = (keyword: string) => descendantKeywords(roots, keyword);
|
||||
|
||||
const add = async () => {
|
||||
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
|
||||
@@ -21,7 +25,7 @@ export function LabelsSettings() {
|
||||
return (
|
||||
<div>
|
||||
<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) => (
|
||||
<div key={l.keyword} className="card">
|
||||
<div className="card-head">
|
||||
@@ -36,6 +40,37 @@ export function LabelsSettings() {
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
||||
</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>
|
||||
))}
|
||||
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
|
||||
|
||||
Reference in New Issue
Block a user