The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.
A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.
The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.
Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.
The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.
596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
54 lines
3.6 KiB
TypeScript
54 lines
3.6 KiB
TypeScript
import { useSession } from "@/store/session";
|
|
import { client } from "@/jmap/client";
|
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
|
import { APP_VERSION } from "@/lib/version";
|
|
import { t, tNode } from "@/lib/i18n";
|
|
|
|
export function AboutSettings() {
|
|
const session = useSession((s) => s.session);
|
|
const caps = Object.keys(session?.capabilities ?? {});
|
|
// A deployment running modified code should offer its own source, not ours.
|
|
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
|
return (
|
|
<div>
|
|
<h1>{t("About ihasmail")}</h1>
|
|
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a> })}</p>
|
|
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
|
<img src="/img/logo.png" alt={t("ihasmail")} width={96} />
|
|
<div>
|
|
{/* A product name and a version string: neither is a word to translate. */}
|
|
<div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
|
|
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
|
|
</div>
|
|
</div>
|
|
<h2>{t("Server")}</h2>
|
|
<table className="sessions-table">
|
|
<tbody>
|
|
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
|
|
<tr><td>{t("Stalwart")}</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
|
|
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
|
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
|
|
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
|
|
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
|
|
<h2>{t("Server capabilities")}</h2>
|
|
<div className="row wrap gap-4">
|
|
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
|
* "1.0.0" wherever it publishes one at all), so the edition is all there is to
|
|
* show. The generation used to be reported here too, back when ihasmail spoke
|
|
* to both 0.15 and 0.16; it requires 0.16 now, so signing in at all is the
|
|
* answer to that question.
|
|
*/
|
|
function describeServer(server: { edition?: string | null } | undefined): string {
|
|
return server?.edition ? `0.16 or newer (${server.edition})` : "0.16 or newer";
|
|
}
|