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.
83 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|