Files
ihasmail-inbuxa/web/src/views/settings/LabelsSettings.tsx
T
jcoffey 9e3844e94a Call the app by its name in every sentence that names it (#406)
APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".

Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.

Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.

All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.

A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
2026-09-19 14:27:34 -07:00

83 lines
4.6 KiB
TypeScript

import { useState } from "react";
import { useAppName } from "@/lib/brand";
import { Plus, Trash2 } from "lucide-react";
import { useSettings, type LabelVisibility } from "@/store/settings";
import { labelTree, descendantKeywords } from "@/lib/mailbox/labelTree";
import { useMemo } from "react";
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { t, tNode } from "@/lib/i18n";
export function LabelsSettings() {
const appName = useAppName();
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") });
if (!name?.trim()) return;
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
if (labels.some((l) => l.keyword === keyword)) return;
update({ labels: [...labels, { keyword, name: name.trim(), color: CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]! }] });
};
return (
<div>
<h1>{t("Labels")}</h1>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are {app}\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.", { app: appName })}</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: l.color, width: 14, height: 14 }} />
{editing === l.keyword ? (
<input className="input sm" autoFocus defaultValue={l.name} onBlur={(e) => { update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, name: e.target.value || x.name } : x)) }); setEditing(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} style={{ width: 240 }} />
) : (
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
)}
<button className="icon-btn sm danger" aria-label={t("Delete label")} onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
</div>
<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>
<p className="hint mt-8">{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: <kbd className="kbd">l</kbd>, operator: <code>label:name</code> })}</p>
</div>
);
}