Use American English spelling throughout
This commit is contained in:
@@ -94,7 +94,7 @@ describe("generated passwords", () => {
|
||||
expect(p).not.toMatch(/[01lIO]/);
|
||||
});
|
||||
|
||||
it("skip bytes that would favour the start of the alphabet", () => {
|
||||
it("skip bytes that would favor the start of the alphabet", () => {
|
||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
describe("the dashboard's message numbers", () => {
|
||||
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
||||
const stats = summariseMetrics([
|
||||
const stats = summarizeMetrics([
|
||||
counter("queue.message-queued", 6),
|
||||
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
||||
counter("queue.authenticated-message-queued", 2),
|
||||
@@ -20,7 +20,7 @@ describe("the dashboard's message numbers", () => {
|
||||
});
|
||||
|
||||
it("reads memory from the newest gauge, not the first one listed", () => {
|
||||
const stats = summariseMetrics([
|
||||
const stats = summarizeMetrics([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
||||
@@ -29,8 +29,8 @@ describe("the dashboard's message numbers", () => {
|
||||
});
|
||||
|
||||
it("tells a history that records nothing from a quiet day", () => {
|
||||
expect(summariseMetrics([]).recorded).toBe(false);
|
||||
const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||
expect(summarizeMetrics([]).recorded).toBe(false);
|
||||
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("the account query", () => {
|
||||
* live server gave, or one its source says it gives.
|
||||
*/
|
||||
describe("refusals in the reader's language", () => {
|
||||
it("recognises the registry's validators and says it again, without the server's words", () => {
|
||||
it("recognizes the registry's validators and says it again, without the server's words", () => {
|
||||
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||
expect(domain).toMatch(/isn't a valid domain name/);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normaliseDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||
|
||||
/**
|
||||
* Written the way Stalwart's BIND serialiser writes it (dns-update's
|
||||
* Written the way Stalwart's BIND serializer writes it (dns-update's
|
||||
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
||||
* parenthesised run of quoted chunks.
|
||||
* parenthesized run of quoted chunks.
|
||||
*/
|
||||
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
||||
const zone = [
|
||||
@@ -46,7 +46,7 @@ describe("reading the zone file", () => {
|
||||
|
||||
describe("domain names", () => {
|
||||
it("are written back lower-case without the root dot", () => {
|
||||
expect(normaliseDomain(" Example.COM. ")).toBe("example.com");
|
||||
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("are checked loosely before the server decides", () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("the span an availability bar covers", () => {
|
||||
expect(w.span).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks a single day every three hours, labelling every six", () => {
|
||||
it("marks a single day every three hours, labeling every six", () => {
|
||||
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
|
||||
expect(w.scale).toBe("hours");
|
||||
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("birthdaysInRange", () => {
|
||||
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to a name built from components, then to the organisation", () => {
|
||||
it("falls back to a name built from components, then to the organization", () => {
|
||||
const [s, e] = range("2026-01-01", "2027-01-01");
|
||||
const parts = {
|
||||
id: "c1",
|
||||
@@ -115,7 +115,7 @@ describe("birthdaysInRange", () => {
|
||||
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
|
||||
});
|
||||
|
||||
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
|
||||
it("gives each occurrence a stable, unique id that marks it as synthesized", () => {
|
||||
const [s, e] = range("2025-01-01", "2027-01-01");
|
||||
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
|
||||
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||
*
|
||||
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
|
||||
* the server rather than have it written in. The sign-in page did not (#236's
|
||||
* neighbour): it fetched `/api/config`, received the name and used only
|
||||
* neighbor): it fetched `/api/config`, received the name and used only
|
||||
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
|
||||
* user meets first. These pin the shape of the answer rather than the name.
|
||||
*/
|
||||
|
||||
@@ -97,14 +97,14 @@ describe("explicit date formats", () => {
|
||||
});
|
||||
|
||||
describe("clock preference", () => {
|
||||
it("honours 24-hour regardless of locale", () => {
|
||||
it("honors 24-hour regardless of locale", () => {
|
||||
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
|
||||
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||
expect(uses24Hour()).toBe(true);
|
||||
expect(formatHourLabel(13)).toBe("13");
|
||||
expect(formatHourLabel(9)).toBe("09");
|
||||
});
|
||||
it("honours 12-hour regardless of locale", () => {
|
||||
it("honors 12-hour regardless of locale", () => {
|
||||
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
|
||||
expect(formatClock(SAMPLE)).toBe("6:23 PM");
|
||||
expect(uses24Hour()).toBe(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The two sentence builders, which had no tests while they were building
|
||||
* English by concatenation -- and no test would have caught the thing wrong
|
||||
* with them, since the English output was correct. These pin the two
|
||||
* properties that matter now: every fragment goes through the catalogue, and
|
||||
* properties that matter now: every fragment goes through the catalog, and
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -12,7 +12,7 @@ import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
describe("sieve describeRule", () => {
|
||||
it("names the header and operator through the catalogue", () => {
|
||||
it("names the header and operator through the catalog", () => {
|
||||
const s = describeSieve({
|
||||
id: "1", name: "r", join: "allof", enabled: true,
|
||||
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
||||
@@ -50,7 +50,7 @@ describe("recurrence describeRule", () => {
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
||||
});
|
||||
|
||||
it("recognises Monday to Friday as every weekday", () => {
|
||||
it("recognizes Monday to Friday as every weekday", () => {
|
||||
const rule = {
|
||||
"@type": "RecurrenceRule", frequency: "weekly",
|
||||
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
||||
@@ -80,12 +80,12 @@ describe("recurrence describeRule", () => {
|
||||
expect(names[0]).toBe("Montag");
|
||||
expect(names).toHaveLength(7);
|
||||
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
||||
// which is why they cannot be catalogue keys and come from Intl instead.
|
||||
// which is why they cannot be catalog keys and come from Intl instead.
|
||||
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
||||
setUiLanguageForFormatting(null);
|
||||
});
|
||||
|
||||
it("renders a translated rule through the catalogue", () => {
|
||||
it("renders a translated rule through the catalog", () => {
|
||||
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
||||
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
||||
setCatalog("en", { strings: {}, plurals: {} });
|
||||
|
||||
@@ -93,14 +93,14 @@ describe("canMoveFolderTo", () => {
|
||||
});
|
||||
|
||||
describe("folderColor", () => {
|
||||
it("returns the colour chosen for that folder, and null for the rest", () => {
|
||||
it("returns the color chosen for that folder, and null for the rest", () => {
|
||||
const colors = { work: "#7c3aed" };
|
||||
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
||||
expect(folderColor(colors, "news")).toBeNull();
|
||||
expect(folderColor({}, "work")).toBeNull();
|
||||
});
|
||||
|
||||
it("is keyed by id, so a renamed folder keeps its colour", () => {
|
||||
it("is keyed by id, so a renamed folder keeps its color", () => {
|
||||
// The id is stable across a rename; the name and path are not.
|
||||
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("sanitizeEmailHtml", () => {
|
||||
});
|
||||
|
||||
describe("htmlDeclaresColors", () => {
|
||||
it("is false for mail that brings no colours", () => {
|
||||
it("is false for mail that brings no colors", () => {
|
||||
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
||||
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
||||
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
||||
@@ -54,9 +54,9 @@ describe("htmlDeclaresColors", () => {
|
||||
/**
|
||||
* Forcing the theme onto mail that styles itself — issue #290.
|
||||
*
|
||||
* The switch above it leaves nearly all HTML mail alone, because one colour
|
||||
* The switch above it leaves nearly all HTML mail alone, because one color
|
||||
* anywhere opts a message out. What this half has to get right is telling a
|
||||
* sheet the design sits on from a surface painted on top of it: neutralise the
|
||||
* sheet the design sits on from a surface painted on top of it: neutralize the
|
||||
* first and the white card goes away, keep the second and a button keeps a
|
||||
* label you can still read.
|
||||
*/
|
||||
@@ -70,15 +70,15 @@ describe("relativeLuminance", () => {
|
||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("has nothing to say about a colour it cannot read", () => {
|
||||
it("has nothing to say about a color it cannot read", () => {
|
||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||
// is the safe way round — an unreadable colour must not keep a white sheet.
|
||||
// is the safe way round — an unreadable color must not keep a white sheet.
|
||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||
expect(relativeLuminance("")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a fully transparent colour as painting nothing", () => {
|
||||
it("treats a fully transparent color as painting nothing", () => {
|
||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||
expect(relativeLuminance("transparent")).toBeNull();
|
||||
});
|
||||
@@ -100,21 +100,21 @@ describe("markKeptSurfaces", () => {
|
||||
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||
* marking. So these assert what the reader actually sees: does the
|
||||
* neutraliser hit this element? The selector is lifted out of the stylesheet
|
||||
* neutralizer hit this element? The selector is lifted out of the stylesheet
|
||||
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||
*/
|
||||
const NEUTRALISER = (() => {
|
||||
const NEUTRALIZER = (() => {
|
||||
const m = EMAIL_BASE_CSS.match(
|
||||
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||
);
|
||||
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
|
||||
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
|
||||
return m[1]!.trim();
|
||||
})();
|
||||
|
||||
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||
const neutralised = (el: Element) => el.matches(NEUTRALISER);
|
||||
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
|
||||
|
||||
it("keeps a coloured button and drops the white sheet around it", () => {
|
||||
it("keeps a colored button and drops the white sheet around it", () => {
|
||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||
@@ -127,7 +127,7 @@ describe("markKeptSurfaces", () => {
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||
});
|
||||
|
||||
it("neutralises a light panel nested inside a dark painted card", () => {
|
||||
it("neutralizes a light panel nested inside a dark painted card", () => {
|
||||
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||
// are dark enough to be marked, with light content tables inside them.
|
||||
// Those tables used to inherit the card's exemption and render as beige
|
||||
@@ -153,11 +153,11 @@ describe("markKeptSurfaces", () => {
|
||||
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||
// being descendants of the card.
|
||||
expect(neutralised(nested)).toBe(true);
|
||||
expect(neutralised(d.querySelector("td")!)).toBe(true);
|
||||
expect(neutralized(nested)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(true);
|
||||
// The card itself is still left alone, and the page surround still goes.
|
||||
expect(neutralised(card)).toBe(false);
|
||||
expect(neutralised(surround)).toBe(true);
|
||||
expect(neutralized(card)).toBe(false);
|
||||
expect(neutralized(surround)).toBe(true);
|
||||
});
|
||||
|
||||
it("still keeps a button that sits inside a nested light panel", () => {
|
||||
@@ -172,11 +172,11 @@ describe("markKeptSurfaces", () => {
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(2);
|
||||
expect(neutralised(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralised(d.querySelector("td")!)).toBe(false);
|
||||
expect(neutralized(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(false);
|
||||
// The label keeps its white, which is the thing #294 bought and this must
|
||||
// not spend.
|
||||
expect(neutralised(d.querySelector("a")!)).toBe(false);
|
||||
expect(neutralized(d.querySelector("a")!)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||
@@ -201,7 +201,7 @@ describe("markKeptSurfaces", () => {
|
||||
expect(panels.length).toBe(21);
|
||||
|
||||
expect(markKeptSurfaces(d)).toBe(7);
|
||||
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
|
||||
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads an inline background as well as the attribute", () => {
|
||||
@@ -233,7 +233,7 @@ describe("markKeptSurfaces", () => {
|
||||
* The control that actually stops it is layout containment on an ancestor of
|
||||
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
||||
* and is asserted at the bottom of this file, because jsdom does no layout and
|
||||
* cannot prove it here. These cover the second line of defence.
|
||||
* cannot prove it here. These cover the second line of defense.
|
||||
*/
|
||||
describe("mail CSS cannot climb out of its card", () => {
|
||||
const render = (html: string) => sanitizeEmailHtml(html).html;
|
||||
@@ -271,7 +271,7 @@ describe("mail CSS cannot climb out of its card", () => {
|
||||
describe("the containment that mail CSS cannot override", () => {
|
||||
it("is still applied to the message body container", async () => {
|
||||
// jsdom does no layout, so this asserts the control is present rather than
|
||||
// that it works; the behaviour was verified in a real browser. Without it,
|
||||
// that it works; the behavior was verified in a real browser. Without it,
|
||||
// a message can cover the viewport regardless of what the sanitizer does.
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("deciding whether a message has an HTML alternative", () => {
|
||||
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the type case-insensitively, since a header may be capitalised", () => {
|
||||
it("matches the type case-insensitively, since a header may be capitalized", () => {
|
||||
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ describe("t", () => {
|
||||
expect(currentLanguage()).toBe("en");
|
||||
});
|
||||
|
||||
it("translates once a catalogue is in force", () => {
|
||||
it("translates once a catalog is in force", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Archive")).toBe("Archivieren");
|
||||
});
|
||||
|
||||
it("falls back per string, not per catalogue", () => {
|
||||
it("falls back per string, not per catalog", () => {
|
||||
setCatalog("de", de);
|
||||
expect(t("Report spam")).toBe("Report spam");
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe("interpolation", () => {
|
||||
describe("plural", () => {
|
||||
const FORMS = { one: "{n} message", other: "{n} messages" };
|
||||
|
||||
it("picks the English form without a catalogue", () => {
|
||||
it("picks the English form without a catalog", () => {
|
||||
expect(plural(1, FORMS)).toBe("1 message");
|
||||
expect(plural(0, FORMS)).toBe("0 messages");
|
||||
expect(plural(5, FORMS)).toBe("5 messages");
|
||||
@@ -73,7 +73,7 @@ describe("plural", () => {
|
||||
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
||||
});
|
||||
|
||||
it("falls back to `other` when the catalogue lacks the category", () => {
|
||||
it("falls back to `other` when the catalog lacks the category", () => {
|
||||
setCatalog("de", de);
|
||||
// German has no "few"; asking for 3 must not render undefined.
|
||||
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
||||
@@ -95,7 +95,7 @@ describe("tNode", () => {
|
||||
|
||||
it("lets a translator move the element", () => {
|
||||
// Splitting the sentence into two t() calls could not do this: the
|
||||
// fragments would render in the English order whatever the catalogue said.
|
||||
// fragments would render in the English order whatever the catalog said.
|
||||
setCatalog("de", de);
|
||||
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
||||
.toBe("<code>mailto:</code>-Links hier öffnen");
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("parseIcsDuration", () => {
|
||||
});
|
||||
|
||||
describe("looksLikeCalendar", () => {
|
||||
it("recognises a calendar and rejects an error page", () => {
|
||||
it("recognizes a calendar and rejects an error page", () => {
|
||||
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
|
||||
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l)
|
||||
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||
|
||||
describe("the document around the events", () => {
|
||||
it("is a calendar a reader will recognise", () => {
|
||||
it("is a calendar a reader will recognize", () => {
|
||||
const l = lines([base]);
|
||||
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
||||
expect(l).toContain("VERSION:2.0");
|
||||
@@ -105,7 +105,7 @@ describe("recurrence", () => {
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
||||
});
|
||||
|
||||
it("turns a cancelled occurrence into an EXDATE", () => {
|
||||
it("turns a canceled occurrence into an EXDATE", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
@@ -166,7 +166,7 @@ describe("the rest of an event", () => {
|
||||
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
||||
});
|
||||
|
||||
it("writes the organiser and the guests, with what each answered", () => {
|
||||
it("writes the organizer and the guests, with what each answered", () => {
|
||||
const e = {
|
||||
...base,
|
||||
organizerCalendarAddress: "mailto:[email protected]",
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("resolveUiLanguage", () => {
|
||||
});
|
||||
|
||||
it("refuses a language whose strings are not shipped", () => {
|
||||
// The account travels between machines and can outlive a catalogue. A
|
||||
// The account travels between machines and can outlive a catalog. A
|
||||
// page that says lang="fr" while rendering English is worse than one that
|
||||
// admits to English: it stops the reader translating it themselves.
|
||||
// Derived rather than named, so shipping another language does not turn
|
||||
@@ -30,7 +30,7 @@ describe("resolveUiLanguage", () => {
|
||||
});
|
||||
|
||||
it("carries the Beta flag until a person has signed the language off", () => {
|
||||
// Not a completeness measure. A catalogue can be word-for-word finished
|
||||
// Not a completeness measure. A catalog can be word-for-word finished
|
||||
// and still read like a machine wrote it, which is what this marks.
|
||||
// Every shipped language except English is unreviewed, and stays marked
|
||||
// until a person says otherwise.
|
||||
@@ -40,12 +40,12 @@ describe("resolveUiLanguage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("honours one that is", () => {
|
||||
it("honors one that is", () => {
|
||||
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
});
|
||||
|
||||
it("only offers languages that resolve to themselves", () => {
|
||||
// Guards the ordering mistake: adding a picker entry before its catalogue.
|
||||
// Guards the ordering mistake: adding a picker entry before its catalog.
|
||||
for (const l of UI_LANGUAGES) {
|
||||
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||
expect(l.name.trim()).not.toBe("");
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("comparatorsFor, custom levels", () => {
|
||||
});
|
||||
|
||||
describe("optional sorts, which a server is allowed to refuse", () => {
|
||||
it("recognises the keyword properties", () => {
|
||||
it("recognizes the keyword properties", () => {
|
||||
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
|
||||
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
@@ -19,7 +19,7 @@ const mb = (id: string, name: string, role: string | null = null, parentId: stri
|
||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||
|
||||
describe("mailboxDisplayName", () => {
|
||||
it("is the server's name until a catalogue says otherwise", () => {
|
||||
it("is the server's name until a catalog says otherwise", () => {
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||
});
|
||||
|
||||
@@ -43,18 +43,18 @@ describe("mailboxDisplayName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalisedName", () => {
|
||||
describe("isLocalizedName", () => {
|
||||
it("tells an editor when the name on screen is not the server's", () => {
|
||||
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||
// the moment somebody pressed Save — a real change made by accident.
|
||||
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailboxDisplayPath", () => {
|
||||
it("localises each part that has a role and leaves the rest", () => {
|
||||
it("localizes each part that has a role and leaves the rest", () => {
|
||||
setCatalog("de", de);
|
||||
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("renderMarkdown", () => {
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitiser.
|
||||
* these renders as a script tag without a sanitizer.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("the rest of the schema", () => {
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
||||
});
|
||||
|
||||
it("reads the organisation, its units and the job title", () => {
|
||||
it("reads the organization, its units and the job title", () => {
|
||||
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
||||
expect(values(c.organizations)[0]).toMatchObject({
|
||||
name: "Example Corp",
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("the palettes themselves", () => {
|
||||
it("has a light and a dark half for every one of them", () => {
|
||||
// The reason there is no "this palette is dark only" machinery: there is
|
||||
// no such palette. ihasmail's own gained a light half, and the override,
|
||||
// the toggle's memory and a greyed-out control all went with it.
|
||||
// the toggle's memory and a grayed-out control all went with it.
|
||||
expect(PALETTES.map((p) => p.id)).toEqual([
|
||||
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
|
||||
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
|
||||
@@ -75,7 +75,7 @@ describe("legacyTheme, read by a device still on an older build", () => {
|
||||
});
|
||||
|
||||
describe("toggleTarget", () => {
|
||||
it("flips the mode and keeps the colours, whatever the palette", () => {
|
||||
it("flips the mode and keeps the colors, whatever the palette", () => {
|
||||
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
|
||||
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
|
||||
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("permission labels", () => {
|
||||
* does not: a missing one would show English in the middle of a translated
|
||||
* picker, and a stale one would never be looked up.
|
||||
*/
|
||||
describe("the permission catalogues", () => {
|
||||
describe("the permission catalogs", () => {
|
||||
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
|
||||
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
|
||||
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
|
||||
|
||||
@@ -7,7 +7,7 @@ import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
||||
* Settings used to live only in localStorage, so nothing followed the user
|
||||
* between devices — issue #54, whose sharpest case is the default identity:
|
||||
* with none set, the address that sorts first wins, so mail goes out from an
|
||||
* address the recipient may not recognise.
|
||||
* address the recipient may not recognize.
|
||||
*
|
||||
* The split is written as a list of exceptions, which means the interesting
|
||||
* test is not "does this key sync" but "does a key added later sync without
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("sharing a file", () => {
|
||||
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
||||
});
|
||||
|
||||
it("raises anything it does not recognise, so a real fault is still reported", async () => {
|
||||
it("raises anything it does not recognize, so a real fault is still reported", async () => {
|
||||
stubNavigator({
|
||||
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
|
||||
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
|
||||
|
||||
describe("signature compaction", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colors and links", () => {
|
||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const out = compactHtml(src);
|
||||
expect(out).not.toContain("mso-");
|
||||
|
||||
@@ -6,8 +6,8 @@ import { catalog as de } from "@/locales/de";
|
||||
|
||||
/**
|
||||
* The briefing is the only thing standing between a notification action and a
|
||||
* button labelled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalogue.
|
||||
* button labeled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalog.
|
||||
*
|
||||
* It is also the only place the archive mailbox is named, and getting that
|
||||
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||
@@ -45,7 +45,7 @@ describe("the worker's briefing", () => {
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalogue. Everything it will say has to be said here
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
setCatalog("de", de);
|
||||
const { store } = fakeCaches();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||
|
||||
/**
|
||||
* A swipe names what it is about to do on a coloured strip the reader sees for
|
||||
* A swipe names what it is about to do on a colored strip the reader sees for
|
||||
* about a third of a second before letting go. These check that the name is
|
||||
* true in the folder it is being read in — which is the whole reason the
|
||||
* descriptor exists rather than a fixed label per setting.
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("remembering the palette you were on", () => {
|
||||
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
|
||||
});
|
||||
|
||||
it("keeps the colours when the palette has both sides", () => {
|
||||
it("keeps the colors when the palette has both sides", () => {
|
||||
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
|
||||
expect(away.palette).toBe("gruvbox");
|
||||
expect(away.mode).toBe("light");
|
||||
|
||||
@@ -65,7 +65,7 @@ const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||
|
||||
describe("isTnef", () => {
|
||||
it("recognises the types and the filename", () => {
|
||||
it("recognizes the types and the filename", () => {
|
||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("lockAxis", () => {
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("counts distance on either axis towards committing", () => {
|
||||
it("counts distance on either axis toward committing", () => {
|
||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
/*
|
||||
* The guard's answers, and which of them the dialog leans on.
|
||||
*
|
||||
* It shipped with "Discard changes" as the only choice carrying a colour, which
|
||||
* It shipped with "Discard changes" as the only choice carrying a color, which
|
||||
* made losing the work the easy thing to click on a dialog whose entire purpose
|
||||
* is to stop that (#175). The emphasis belongs on the safe answer; the
|
||||
* destructive one stays legible as destructive without being the loudest thing
|
||||
|
||||
@@ -42,7 +42,7 @@ const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
||||
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
||||
|
||||
/**
|
||||
* The account to read and write for this capability, honouring the switcher.
|
||||
* The account to read and write for this capability, honoring the switcher.
|
||||
*
|
||||
* Use for anything the reader is looking at: their mail, a shared calendar,
|
||||
* somebody's files. Not for anything of the reader's own — see below.
|
||||
|
||||
@@ -107,7 +107,7 @@ export interface RoleDef {
|
||||
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||
* and treats such an account as read-only.
|
||||
*
|
||||
* It errs towards refusing. A role that cannot be read -- the viewer lacks
|
||||
* It errs toward refusing. A role that cannot be read -- the viewer lacks
|
||||
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface MessageStats {
|
||||
recorded: boolean;
|
||||
}
|
||||
|
||||
export function summariseMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||
export function summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let memory: MessageStats["memory"] = null;
|
||||
|
||||
@@ -244,7 +244,7 @@ export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" |
|
||||
* Stalwart explains a refusal in English, and its words are never shown as
|
||||
* they are: an interface in German that answers in English reads as broken
|
||||
* even when the English is exact. Every type the registry returns has its
|
||||
* own message, and a value a validator refused is recognised by the
|
||||
* own message, and a value a validator refused is recognized by the
|
||||
* validator's wording and said again here.
|
||||
*
|
||||
* One exception, on purpose. A password policy is the server's to set -- a
|
||||
@@ -282,16 +282,16 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return object === "domain"
|
||||
? t("Your organisation has reached the number of domains it is allowed.")
|
||||
? t("Your organization has reached the number of domains it is allowed.")
|
||||
: object === "group"
|
||||
? t("Your organisation has reached the number of groups it is allowed.")
|
||||
? t("Your organization has reached the number of groups it is allowed.")
|
||||
: object === "list"
|
||||
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
||||
? t("Your organization has reached the number of mailing lists it is allowed.")
|
||||
: object === "role"
|
||||
? t("Your organisation has reached the number of roles it is allowed.")
|
||||
? t("Your organization has reached the number of roles it is allowed.")
|
||||
: object === "tenant"
|
||||
? t("The server allows no more tenants.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
: t("Your organization has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
|
||||
@@ -127,18 +127,18 @@ export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Pr
|
||||
}
|
||||
|
||||
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
||||
export function normaliseDomain(name: string): string {
|
||||
export function normalizeDomain(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
/** Enough of a check to catch a typo before the server does; the server decides. */
|
||||
export function looksLikeDomain(name: string): boolean {
|
||||
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normaliseDomain(name));
|
||||
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normalizeDomain(name));
|
||||
}
|
||||
|
||||
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Domain/set", {
|
||||
create: { n: { name: normaliseDomain(input.name), description: input.description.trim() || null } },
|
||||
create: { n: { name: normalizeDomain(input.name), description: input.description.trim() || null } },
|
||||
});
|
||||
refused(res, "notCreated");
|
||||
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
||||
@@ -179,8 +179,8 @@ export interface DnsRecord {
|
||||
/**
|
||||
* Read the zone file Stalwart computes for a domain.
|
||||
*
|
||||
* Its serialiser writes one record per line as `name IN TYPE value`, and a TXT
|
||||
* record longer than 255 bytes as a parenthesised run of quoted strings, one
|
||||
* Its serializer writes one record per line as `name IN TYPE value`, and a TXT
|
||||
* record longer than 255 bytes as a parenthesized run of quoted strings, one
|
||||
* per line. A DNS provider's form wants the whole value, so the strings are
|
||||
* joined and unescaped; the original lines are kept for anyone pasting into a
|
||||
* zone. Anything that does not parse is kept too, as its own row, rather than
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stalwart's labelled permission list, through ihasmail's server. */
|
||||
/** Stalwart's labeled permission list, through ihasmail's server. */
|
||||
export async function loadPermissionList(): Promise<PermissionInfo[]> {
|
||||
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
|
||||
return res.permissions;
|
||||
|
||||
@@ -53,8 +53,8 @@ function bodyText(email: Email): string {
|
||||
* Everyone the message was between, as guests: the sender and the people it
|
||||
* was addressed to.
|
||||
*
|
||||
* The reader's own addresses come out -- they are the organiser, and an
|
||||
* organiser listed among their own guests is an event that invites you to your
|
||||
* The reader's own addresses come out -- they are the organizer, and an
|
||||
* organizer listed among their own guests is an event that invites you to your
|
||||
* own appointment. Bcc stays out too, on a message the reader sent themselves:
|
||||
* a blind recipient added to a guest list is visible to every other guest, and
|
||||
* turning a hidden copy into a public one is not something a menu item should
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
|
||||
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
|
||||
* normaliser -- so this is the same answer the server reached, not a second
|
||||
* normalizer -- so this is the same answer the server reached, not a second
|
||||
* guess at it. Reading it here rather than re-deriving it from
|
||||
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
|
||||
* there is nothing in the address that says how much of it is the mount.
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface Birthday {
|
||||
age: number | null;
|
||||
}
|
||||
|
||||
/** The prefix marking a synthesised event, so nothing tries to save one. */
|
||||
/** The prefix marking a synthesized event, so nothing tries to save one. */
|
||||
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
|
||||
|
||||
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
|
||||
|
||||
@@ -64,7 +64,7 @@ export function withPrefs<T>(over: Partial<DateTimePrefs>, fn: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale reported by Stalwart for this account (normalised), or null. */
|
||||
/** Locale reported by Stalwart for this account (normalized), or null. */
|
||||
export function setServerLocale(raw: string | null | undefined): void {
|
||||
serverLocale = normalizeLocale(raw);
|
||||
}
|
||||
@@ -193,7 +193,7 @@ function num(value: number, digits: number): string {
|
||||
return f.format(value);
|
||||
}
|
||||
|
||||
/** Time-of-day options honouring the 12h/24h preference. */
|
||||
/** Time-of-day options honoring the 12h/24h preference. */
|
||||
export function timeOptions(): Intl.DateTimeFormatOptions {
|
||||
switch (prefs.timeFormat) {
|
||||
case "24":
|
||||
@@ -562,13 +562,13 @@ export function localeOptions(): LocaleOption[] {
|
||||
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
|
||||
*
|
||||
* These used to be a table of English strings with a `short` of "M", "T", "W"…
|
||||
* which could not become catalogue entries at all: "T" is both Tuesday and
|
||||
* which could not become catalog entries at all: "T" is both Tuesday and
|
||||
* Thursday and "S" is both Saturday and Sunday, so the key collides with
|
||||
* itself. A catalogue cannot hold two translations under one key, and no
|
||||
* itself. A catalog cannot hold two translations under one key, and no
|
||||
* amount of translating fixes that — the data was wrong, not the wiring.
|
||||
*
|
||||
* Intl has the names already, in every locale, in three widths, and gets the
|
||||
* plural and capitalisation conventions right without anybody maintaining a
|
||||
* plural and capitalization conventions right without anybody maintaining a
|
||||
* list. 2026-06-01 is a Monday; the rest follow from it.
|
||||
*/
|
||||
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
||||
|
||||
@@ -27,7 +27,7 @@ function unsafe(ch: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
|
||||
* Long enough to stay recognizable, short enough to survive a 255-*byte* limit
|
||||
* once a CJK subject is three bytes a character.
|
||||
*/
|
||||
const MAX = 80;
|
||||
|
||||
@@ -54,7 +54,7 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
||||
* legal moves behind a disabled drop. The server refuses those with a message
|
||||
* of its own, which is a better answer than a silent one.
|
||||
*/
|
||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||
/** The MIME a dragged node is offered under, so a target can recognize it. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,7 +66,7 @@ export function canMoveFolderTo(mailboxes: Record<Id, Mailbox>, id: Id, targetId
|
||||
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
|
||||
}
|
||||
|
||||
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
||||
/** The color chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
||||
export function folderColor(colors: Record<string, string>, id: Id): string | null {
|
||||
return colors[id] ?? null;
|
||||
}
|
||||
|
||||
+16
-16
@@ -50,7 +50,7 @@ function ensureHooks() {
|
||||
* convincing fake over the whole app. The control that actually stops that is
|
||||
* layout containment on an ancestor of the shadow host (see `.message-body` in
|
||||
* app.css), which mail CSS has no selector for. This is the second line:
|
||||
* neutralise the declarations themselves, and defang `:host`, which is how mail
|
||||
* neutralize the declarations themselves, and defang `:host`, which is how mail
|
||||
* CSS would otherwise reach the host element.
|
||||
*/
|
||||
function hardenCss(css: string): string {
|
||||
@@ -183,7 +183,7 @@ export const EMAIL_BASE_CSS = `
|
||||
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
||||
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
||||
|
||||
/* "Follow the app theme" — only applied to mail that brings no colours of its
|
||||
/* "Follow the app theme" — only applied to mail that brings no colors of its
|
||||
own. The custom properties are inherited from the host document, so a theme
|
||||
switch repaints the message without re-rendering it. */
|
||||
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
|
||||
@@ -193,7 +193,7 @@ export const EMAIL_BASE_CSS = `
|
||||
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
||||
|
||||
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
||||
.themed. Everything the sender coloured is neutralised except the surfaces
|
||||
.themed. Everything the sender colored is neutralized except the surfaces
|
||||
marked by markKeptSurfaces() and what it marked as sitting on them, so a
|
||||
white wrapper table
|
||||
stops being a bright card while a blue button keeps its white label. The
|
||||
@@ -205,7 +205,7 @@ export const EMAIL_BASE_CSS = `
|
||||
`;
|
||||
|
||||
/**
|
||||
* Does this message paint itself? Mail that sets a background or text colour
|
||||
* Does this message paint itself? Mail that sets a background or text color
|
||||
* has a design of its own, and forcing a dark palette on half of it is worse
|
||||
* than leaving it alone — so those keep the light card they were built for.
|
||||
*
|
||||
@@ -227,7 +227,7 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
||||
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
||||
|
||||
/**
|
||||
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
|
||||
* Relative luminance per WCAG 2.x, or `null` when the color cannot be read.
|
||||
*
|
||||
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
||||
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
||||
@@ -263,7 +263,7 @@ export function relativeLuminance(color: string): number | null {
|
||||
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
||||
}
|
||||
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
||||
// A fully transparent colour paints nothing, whatever its channels say.
|
||||
// A fully transparent color paints nothing, whatever its channels say.
|
||||
if (a === 0) return null;
|
||||
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
@@ -272,7 +272,7 @@ export function relativeLuminance(color: string): number | null {
|
||||
/**
|
||||
* Above this, a background is a sheet the message is laid on rather than a
|
||||
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
||||
* action lands near 0.09, mid-grey near 0.22.
|
||||
* action lands near 0.09, mid-gray near 0.22.
|
||||
*/
|
||||
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
||||
|
||||
@@ -288,26 +288,26 @@ function declaredLuminance(el: HTMLElement): number | null {
|
||||
*
|
||||
* The reader has asked for their palette on mail that brings its own, which
|
||||
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
||||
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
|
||||
* design sits on, which is what reads as a bright card and is neutralised, and
|
||||
* makes. What it can do is tell the two kinds of color apart: a **sheet** the
|
||||
* design sits on, which is what reads as a bright card and is neutralized, and
|
||||
* a **painted surface** — a button, a banner — which is kept whole so its
|
||||
* label stays legible on it.
|
||||
*
|
||||
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
|
||||
* keeps its own colours. `data-ihm-in-keep` is an element sitting on one with
|
||||
* no background of its own, whose colour is left alone so a white label on a
|
||||
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralises
|
||||
* keeps its own colors. `data-ihm-in-keep` is an element sitting on one with
|
||||
* no background of its own, whose color is left alone so a white label on a
|
||||
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralizes
|
||||
* everything else.
|
||||
*
|
||||
* The distinction that matters is that being *inside* a painted surface is not
|
||||
* inherited past a sheet. A light table nested in a dark 600px card is still a
|
||||
* sheet and is still neutralised — that is issue #310, where a dark campaign
|
||||
* sheet and is still neutralized — that is issue #310, where a dark campaign
|
||||
* rendered with beige cards inside it because the exemption used to be
|
||||
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
|
||||
* below it: a dark button inside that nested table is kept as usual.
|
||||
*
|
||||
* Nothing the sender wrote is removed, so turning the switch off puts the
|
||||
* message back exactly as it was — and a colour that arrived from a `<style>`
|
||||
* message back exactly as it was — and a color that arrived from a `<style>`
|
||||
* block rather than an attribute is covered too, which is most of them in
|
||||
* modern templates.
|
||||
*/
|
||||
@@ -336,11 +336,11 @@ export function markKeptSurfaces(root: ParentNode): number {
|
||||
kept++;
|
||||
childrenOnPaint = true;
|
||||
} else if (lum !== null) {
|
||||
// A sheet, wherever it sits. Left unmarked so it neutralises, and it
|
||||
// A sheet, wherever it sits. Left unmarked so it neutralizes, and it
|
||||
// ends the protection rather than passing it on.
|
||||
childrenOnPaint = false;
|
||||
} else if (onPaint) {
|
||||
// No background of its own, sitting on paint: leave its colour alone.
|
||||
// No background of its own, sitting on paint: leave its color alone.
|
||||
el.setAttribute("data-ihm-in-keep", "");
|
||||
}
|
||||
|
||||
|
||||
+16
-16
@@ -5,9 +5,9 @@ import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
|
||||
* Translation, in about as little machinery as the job takes.
|
||||
*
|
||||
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
|
||||
* catalogue is loaded and returns the English if it is not there, which buys
|
||||
* catalog is loaded and returns the English if it is not there, which buys
|
||||
* three things worth more than tidy symbolic keys: there is no English
|
||||
* catalogue to keep in step with the code, a missing translation degrades to
|
||||
* catalog to keep in step with the code, a missing translation degrades to
|
||||
* readable English rather than to `mail.list.archive`, and extracting a string
|
||||
* is wrapping it rather than inventing a name for it. Names are where
|
||||
* extraction stalls -- 55 components is a lot of small naming arguments.
|
||||
@@ -71,7 +71,7 @@ export function t(source: string, vars?: Vars): string {
|
||||
*
|
||||
* So a context can be given, and the lookup becomes context + source while the
|
||||
* fallback stays the plain English. A translator sees the context and knows
|
||||
* which sense to render; a catalogue that has not got round to it still
|
||||
* which sense to render; a catalog that has not got round to it still
|
||||
* renders the English word, which was right in English all along.
|
||||
*
|
||||
* The separator is a control character rather than a punctuation mark, which
|
||||
@@ -91,7 +91,7 @@ export function tc(context: string, source: string, vars?: Vars): string {
|
||||
* Two forms is an English assumption and does not survive the second phase of
|
||||
* this: Russian and Ukrainian use three, and picking between them is not
|
||||
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
|
||||
* knows, so the catalogue supplies the forms and the runtime picks.
|
||||
* knows, so the catalog supplies the forms and the runtime picks.
|
||||
*
|
||||
* The English `other` form is the key, so a call site reads as the sentence it
|
||||
* produces and needs no invented name.
|
||||
@@ -143,7 +143,7 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Subscribe to catalogue changes without React. Used by the tests. */
|
||||
/** Subscribe to catalog changes without React. Used by the tests. */
|
||||
export function subscribeForTest(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => void listeners.delete(fn);
|
||||
@@ -155,27 +155,27 @@ export function currentLanguage(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a catalogue in force.
|
||||
* Put a catalog in force.
|
||||
*
|
||||
* Exported for tests and for the loader; nothing else should call it, because
|
||||
* the tag and the catalogue have to move together or `plural` selects with one
|
||||
* the tag and the catalog have to move together or `plural` selects with one
|
||||
* language's rules against another's forms.
|
||||
*/
|
||||
export function setCatalog(tag: string, catalog: Catalog): void {
|
||||
/*
|
||||
* Publishing only when something actually changed is not an optimisation
|
||||
* Publishing only when something actually changed is not an optimization
|
||||
* here, it is the thing that stops an infinite loop.
|
||||
*
|
||||
* The root keys its tree on the language version, so a publish remounts
|
||||
* everything. Remounting re-runs the effect that fetches the account's
|
||||
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
||||
* back here -- with the identical tag and the identical catalogue. Publishing
|
||||
* back here -- with the identical tag and the identical catalog. Publishing
|
||||
* that non-change bumped the version again and went round for ever: the
|
||||
* message list refetched on every pass, which is what it looked like from
|
||||
* the outside.
|
||||
*
|
||||
* Reference equality is enough. `EMPTY` is a module constant and a
|
||||
* dynamically imported catalogue is cached, so the same language really does
|
||||
* dynamically imported catalog is cached, so the same language really does
|
||||
* hand back the same object.
|
||||
*/
|
||||
if (currentTag === tag && current === catalog) return;
|
||||
@@ -188,16 +188,16 @@ export function setCatalog(tag: string, catalog: Catalog): void {
|
||||
* Load and apply a language.
|
||||
*
|
||||
* English is the built-in: it is the source text, so there is nothing to fetch
|
||||
* and no chance of a missing catalogue leaving the app blank. Everything else
|
||||
* and no chance of a missing catalog leaving the app blank. Everything else
|
||||
* is a dynamic import, so a reader who never leaves English never downloads a
|
||||
* catalogue -- which matters, because the main bundle is already large enough
|
||||
* catalog -- which matters, because the main bundle is already large enough
|
||||
* to warn about.
|
||||
*/
|
||||
/**
|
||||
* The catalogue load that is in flight, so the first paint can wait for it.
|
||||
* The catalog load that is in flight, so the first paint can wait for it.
|
||||
*
|
||||
* Without this, a cold load paints before the catalogue lands. Components
|
||||
* recover -- the tree is rebuilt when the catalogue arrives -- but a string
|
||||
* Without this, a cold load paints before the catalog lands. Components
|
||||
* recover -- the tree is rebuilt when the catalog arrives -- but a string
|
||||
* computed in an effect does not: a toast fired in that window is emitted in
|
||||
* English and stays English, in an interface that is otherwise German.
|
||||
* Reported as a stale-folder toast that ignored the language setting.
|
||||
@@ -224,7 +224,7 @@ async function loadLanguageNow(tag: string): Promise<void> {
|
||||
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
|
||||
setCatalog(resolved, mod.catalog);
|
||||
} catch {
|
||||
// A catalogue that will not load leaves English in force rather than a
|
||||
// A catalog that will not load leaves English in force rather than a
|
||||
// half-rendered page. `resolveUiLanguage` should already have prevented
|
||||
// this; it being reachable at all is why it is caught.
|
||||
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* is the reverse, which is why `uiLanguage` and `locale` are separate settings
|
||||
* rather than one.
|
||||
*
|
||||
* Adding a language means adding its catalogue and then adding it here, in
|
||||
* Adding a language means adding its catalog and then adding it here, in
|
||||
* that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout
|
||||
* work well beyond strings, so they are not simply a matter of another entry.
|
||||
*/
|
||||
@@ -26,8 +26,8 @@ export interface UiLanguage {
|
||||
/**
|
||||
* Machine-translated and not yet checked by somebody who speaks it.
|
||||
*
|
||||
* Stays true until a native speaker has actually read the catalogue and said
|
||||
* so. It is not a measure of how complete the file is -- a catalogue can be
|
||||
* Stays true until a native speaker has actually read the catalog and said
|
||||
* so. It is not a measure of how complete the file is -- a catalog can be
|
||||
* word-for-word finished and still read like a machine wrote it, which is
|
||||
* the thing this flag is about. Removing it is a deliberate act by a person,
|
||||
* not something a coverage number earns.
|
||||
@@ -56,8 +56,8 @@ export const DEFAULT_UI_LANGUAGE = "en";
|
||||
/**
|
||||
* The language to actually render in.
|
||||
*
|
||||
* A stored preference is only honoured if its strings are still shipped: a
|
||||
* catalogue can be withdrawn, and an account carrying `de` from another
|
||||
* A stored preference is only honored if its strings are still shipped: a
|
||||
* catalog can be withdrawn, and an account carrying `de` from another
|
||||
* machine must not leave this one claiming to be German while showing English.
|
||||
*/
|
||||
export function resolveUiLanguage(stored: string | undefined | null): string {
|
||||
|
||||
+4
-4
@@ -109,7 +109,7 @@ export function parseLdif(text: string): LdifRecord[] {
|
||||
/**
|
||||
* An identity for an entry, derived from its distinguished name.
|
||||
*
|
||||
* Mozilla's schema has no UID, so a re-import had nothing to be recognised by
|
||||
* Mozilla's schema has no UID, so a re-import had nothing to be recognized by
|
||||
* and duplicated everything (#223). The `dn` is what the file actually carries,
|
||||
* and it does not need to be a durable identity to answer the only question
|
||||
* being asked of it: have I imported this exact entry before? A migration is
|
||||
@@ -123,7 +123,7 @@ export function parseLdif(text: string): LdifRecord[] {
|
||||
* *same* address book, are one contact afterwards. Matching is per book, so
|
||||
* filing two directories in two books keeps them apart.
|
||||
*
|
||||
* Normalised for case and for the spacing exporters differ in, which costs
|
||||
* Normalized for case and for the spacing exporters differ in, which costs
|
||||
* nothing when a file is compared against itself and helps when it is compared
|
||||
* against a differently-produced export of the same directory.
|
||||
*
|
||||
@@ -131,10 +131,10 @@ export function parseLdif(text: string): LdifRecord[] {
|
||||
* and duplicates on re-import, as everything did before.
|
||||
*/
|
||||
export function uidFromDn(dn: string): string | null {
|
||||
const normalised = dn
|
||||
const normalized = dn
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*([,=])\s*/g, "$1");
|
||||
return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null;
|
||||
return normalized ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalized)}` : null;
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ const ROLE_NAMES: Record<string, () => string> = {
|
||||
/** The folder's name as the reader should see it. */
|
||||
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
|
||||
if (!mailbox) return "";
|
||||
const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
|
||||
return localised ? localised() : mailbox.name;
|
||||
const localized = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
|
||||
return localized ? localized() : mailbox.name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ export function mailboxDisplayName(mailbox: { name: string; role?: string | null
|
||||
* they were only looking at. Renaming a role folder is refused anyway, but
|
||||
* relying on that would be relying on a rule enforced somewhere else.
|
||||
*/
|
||||
export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
|
||||
export function isLocalizedName(mailbox: { role?: string | null } | null | undefined): boolean {
|
||||
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { marked } from "marked";
|
||||
* something is DOMPurify, which the app already carries for mail.
|
||||
*
|
||||
* Rendered inline rather than in a shadow root the way mail bodies are: this
|
||||
* output is ours, sanitised and styled by `.md-body`, where an email arrives
|
||||
* output is ours, sanitized and styled by `.md-body`, where an email arrives
|
||||
* with a design of its own that has to be quarantined from the app's.
|
||||
*/
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export function cardFromLdif(rec: LdifRecord): Partial<ContactCard> | null {
|
||||
/*
|
||||
* The four custom fields have nowhere of their own to go: JSContact has no
|
||||
* equivalent, and the schema does not say what they hold -- they are whatever
|
||||
* their owner decided. Appending them to the note keeps them, labelled the
|
||||
* their owner decided. Appending them to the note keeps them, labeled the
|
||||
* way Thunderbird labels them, which is worth more than the tidiness of
|
||||
* dropping something somebody chose to write down.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The theme used to be one enum — `system | light | dark | ihasmail` — where
|
||||
* "ihasmail" carried a whole palette and implied dark. That works for exactly
|
||||
* one palette. With several, the two questions come apart: **which palette**
|
||||
* (the colours) and **which mode** (light or dark), and they are chosen
|
||||
* (the colors) and **which mode** (light or dark), and they are chosen
|
||||
* separately.
|
||||
*
|
||||
* Every palette here is taken from the project that publishes it, all MIT, and
|
||||
@@ -32,7 +32,7 @@ export interface PaletteMeta {
|
||||
* the rest -- and are rendered translate="no" so a page translator leaves
|
||||
* them alone. "Classic" is not a name, it is an adjective describing the
|
||||
* theme, and a German reader should see "Klassisch". Reported by a native
|
||||
* speaker reviewing the German catalogue (#247).
|
||||
* speaker reviewing the German catalog (#247).
|
||||
*/
|
||||
translatable?: boolean;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const PALETTES: PaletteMeta[] = [
|
||||
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
|
||||
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
|
||||
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
|
||||
// Named for the design system rather than for GitHub: the colours are MIT,
|
||||
// Named for the design system rather than for GitHub: the colors are MIT,
|
||||
// the name and the logo are trademarks, and nothing here is endorsed.
|
||||
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
|
||||
];
|
||||
@@ -67,7 +67,7 @@ export function paletteMeta(id: PaletteId | string | null | undefined): PaletteM
|
||||
* against the OS. That was not true while `ihasmail` was dark-only: the mode
|
||||
* then had to be overridden by the palette, and the toggle had to remember
|
||||
* which palette it had set aside on the way to light. Giving that palette a
|
||||
* light half removed the override, the memory and the greyed-out control in
|
||||
* light half removed the override, the memory and the grayed-out control in
|
||||
* one go.
|
||||
*/
|
||||
export function effectiveMode(mode: Mode, prefersDark: boolean): ResolvedMode {
|
||||
|
||||
@@ -50,7 +50,7 @@ export function splitLabel(label: string): { categoryKey: string; action: string
|
||||
|
||||
const loaded = new Map<string, Promise<PermissionCatalog | null>>();
|
||||
|
||||
/** The catalogue for a language, or null for English and for a language without a file. */
|
||||
/** The catalog for a language, or null for English and for a language without a file. */
|
||||
export function loadPermissionCatalog(tag: string = currentLanguage()): Promise<PermissionCatalog | null> {
|
||||
if (tag === DEFAULT_UI_LANGUAGE) return Promise.resolve(null);
|
||||
let pending = loaded.get(tag);
|
||||
|
||||
@@ -7,8 +7,8 @@ import { plural, t } from "@/lib/i18n";
|
||||
*
|
||||
* This was a table of English strings carrying `label: "Monday"` and
|
||||
* `short: "M"`, rendered straight into the picker. The long names could have
|
||||
* become catalogue entries; the short ones could not, because "T" is both
|
||||
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
|
||||
* become catalog entries; the short ones could not, because "T" is both
|
||||
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalog
|
||||
* cannot hold two translations under one key. Intl knows all of them.
|
||||
*/
|
||||
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||
@@ -61,7 +61,7 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
|
||||
*
|
||||
* Built as whole sentences with placeholders rather than by concatenation.
|
||||
* The old version appended fragments -- `base += " on " + names` -- which is
|
||||
* untranslatable however complete the catalogue is: German puts the weekday
|
||||
* untranslatable however complete the catalog is: German puts the weekday
|
||||
* list somewhere else in the clause, and a translator handed " on " alone
|
||||
* cannot move it. Every branch below is one key a translator can rewrite in
|
||||
* full, including the word order.
|
||||
@@ -144,7 +144,7 @@ export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string
|
||||
* "first", "second", "last" -- words, not "1st".
|
||||
*
|
||||
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
|
||||
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
|
||||
* rules in code: German writes "1.", Japanese "第1", and no catalog can
|
||||
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
|
||||
* in practice, so five words and "last" cover it; anything else falls back to
|
||||
* the bare number, which is wrong in no language.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Settings that follow the account rather than the browser.
|
||||
*
|
||||
* Everything used to live in localStorage, which meant no preference travelled
|
||||
* Everything used to live in localStorage, which meant no preference traveled
|
||||
* between devices — most painfully the default identity, where the fallback is
|
||||
* whichever address sorts first, so a forgotten setting sends mail from an
|
||||
* address the recipient may not know (issue #54).
|
||||
@@ -152,7 +152,7 @@ export async function flushSettingsPush(): Promise<void> {
|
||||
if (!pending || !armed) return;
|
||||
const body = pending;
|
||||
pending = null;
|
||||
// Serialise: two overlapping writes could land in either order.
|
||||
// Serialize: two overlapping writes could land in either order.
|
||||
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
|
||||
await inFlight;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function collectShare(): Promise<SharedContent | null> {
|
||||
* The bytes, rather than the Blob holding them.
|
||||
*
|
||||
* `new File([blob], …)` is correct and works in a browser, but a Blob
|
||||
* only counts as a part where the File constructor recognises it as one
|
||||
* only counts as a part where the File constructor recognizes it as one
|
||||
* -- and where it does not, it is stringified instead, producing a file
|
||||
* containing the thirteen characters "[object Blob]" and no error
|
||||
* anywhere. That is exactly what CI caught on Node 22 while it passed
|
||||
|
||||
@@ -183,7 +183,7 @@ export function rulesToSieve(rules: SieveRule[]): string {
|
||||
*
|
||||
* Saving replaces the whole script with a fresh serialization of the rules read
|
||||
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
||||
* the alarm by itself: it skips what it does not recognise, so a script cut off
|
||||
* the alarm by itself: it skips what it does not recognize, so a script cut off
|
||||
* partway through parses cleanly into a shorter list and looks exactly like one
|
||||
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
||||
* truncated download, a plausible parse, and a save that wrote the short
|
||||
@@ -326,10 +326,10 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
|
||||
*
|
||||
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
||||
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
||||
* -- which no catalogue could fix: German puts the verb last, Japanese does
|
||||
* -- which no catalog could fix: German puts the verb last, Japanese does
|
||||
* not separate list items with a word at all, and a translator handed " and "
|
||||
* on its own cannot move anything. Reported by a native speaker reviewing the
|
||||
* German catalogue (#247).
|
||||
* German catalog (#247).
|
||||
*
|
||||
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
||||
* for an anyof rule, the disjunction the language actually uses.
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("what the signature is allowed to mean", () => {
|
||||
expect(shouldRemember(report)).toBe(true);
|
||||
});
|
||||
|
||||
it("the same certificate again is recognised", async () => {
|
||||
it("the same certificate again is recognized", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||
@@ -129,7 +129,7 @@ describe("matching a certificate to an address", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalisation", () => {
|
||||
describe("canonicalization", () => {
|
||||
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
||||
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
||||
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
||||
@@ -141,7 +141,7 @@ describe("canonicalisation", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* The reason canonicalisation is applied at all: a store that hands back a
|
||||
* The reason canonicalization is applied at all: a store that hands back a
|
||||
* message with bare LFs would otherwise fail every signature it holds, and
|
||||
* the message would look identical on screen while doing it.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* This works on bytes, not on a string, and that is the whole point. A
|
||||
* signature is over an octet sequence: decode it to text, re-encode it, or let
|
||||
* anything normalise a line ending on the way past, and the digest changes
|
||||
* anything normalize a line ending on the way past, and the digest changes
|
||||
* while the message still looks identical on screen. Every part here keeps a
|
||||
* subarray of the original buffer rather than a rebuilt copy.
|
||||
*
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* without a certificate authority anywhere in the picture.
|
||||
*
|
||||
* So nothing here ever renders the bare word "verified". The caller is given
|
||||
* the crypto result and the trust judgement separately, and has to say both.
|
||||
* the crypto result and the trust judgment separately, and has to say both.
|
||||
*/
|
||||
import { parseSignedData, type SignerInfo } from "./cms";
|
||||
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
export interface SpamRule {
|
||||
/** The rule's own name, as the filter wrote it. */
|
||||
name: string;
|
||||
/** What it contributed. Negative moves the message towards clean. */
|
||||
/** What it contributed. Negative moves the message toward clean. */
|
||||
score: number;
|
||||
/** Rspamd's bracketed note, where there is one. */
|
||||
detail?: string;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* address book on it.
|
||||
*
|
||||
* Reads are gated as well as writes. A machine that was trusted once still has
|
||||
* the residue, and honouring it would let a previous session's data surface in
|
||||
* the residue, and honoring it would let a previous session's data surface in
|
||||
* a later untrusted one.
|
||||
*/
|
||||
const PREFIX = "ihasmail:";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* What the service worker cannot work out for itself.
|
||||
*
|
||||
* The worker can act on mail — see the note on `jmap()` in sw.js — but it
|
||||
* cannot read a catalogue or a store. It is plain JavaScript copied into the
|
||||
* cannot read a catalog or a store. It is plain JavaScript copied into the
|
||||
* build, outside the bundle, with no i18n and no idea which mailbox is the
|
||||
* archive. Both of those are things a tab knows and can simply write down.
|
||||
*
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface SwipeDescriptor {
|
||||
action: Exclude<SwipeAction, "none">;
|
||||
label: string;
|
||||
icon: SwipeIcon;
|
||||
/** Which colour the strip behind the row takes. */
|
||||
/** Which color the strip behind the row takes. */
|
||||
tone: "danger" | "warn" | "accent" | "neutral";
|
||||
/**
|
||||
* Whether firing it takes the row out of the list. Those slide the rest of
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* mistake to undo.
|
||||
* - **A name that is not a placeholder is left alone too.** Templates are
|
||||
* written by hand and `{{` is not reserved anywhere else, but a body that
|
||||
* silently ate an unrecognised token would be worse than one that shows it.
|
||||
* silently ate an unrecognized token would be worse than one that shows it.
|
||||
*
|
||||
* Dates and times go through `datetime.ts` rather than `toLocaleDateString`,
|
||||
* so a template follows the same date order and clock the rest of the app was
|
||||
|
||||
@@ -41,7 +41,7 @@ export type Axis = "x" | "y" | null;
|
||||
/**
|
||||
* Which way a drag has committed, once it has moved far enough to tell.
|
||||
*
|
||||
* Deliberately biased towards the vertical. Scrolling is what a finger on a
|
||||
* Deliberately biased toward the vertical. Scrolling is what a finger on a
|
||||
* message list is doing almost every time, and a scroll misread as a swipe
|
||||
* grabs the list out from under the reader, while a swipe misread as a scroll
|
||||
* costs them a second attempt. So `x` has to win clearly -- a drag that is
|
||||
@@ -67,7 +67,7 @@ export function swipeThreshold(width: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the row actually moves for a finger that has travelled `dx`.
|
||||
* How far the row actually moves for a finger that has traveled `dx`.
|
||||
*
|
||||
* One-to-one until the action would fire, and increasingly reluctant after
|
||||
* that. The resistance is the only thing that tells a thumb, without the
|
||||
@@ -113,7 +113,7 @@ export function pullDistance(dy: number): number {
|
||||
* without this the only feedback arrives after the row has already gone.
|
||||
*
|
||||
* iOS supports none of this and never has, so this is silently nothing there
|
||||
* rather than something to apologise for. Wrapped because a vibration inside
|
||||
* rather than something to apologize for. Wrapped because a vibration inside
|
||||
* a cross-origin iframe throws rather than returning false.
|
||||
*/
|
||||
export function haptic(pattern: number | number[] = 8): void {
|
||||
@@ -197,7 +197,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
|
||||
if (onLongPress) {
|
||||
timer.current = window.setTimeout(() => {
|
||||
timer.current = null;
|
||||
// Still here, still not moving: nothing has cancelled us.
|
||||
// Still here, still not moving: nothing has canceled us.
|
||||
if (!start.current || axis.current) return;
|
||||
swallowClick.current = true;
|
||||
onLongPress(start.current.target);
|
||||
@@ -251,7 +251,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
|
||||
/*
|
||||
* Crossing back the other way mid-gesture. The direction is re-read
|
||||
* rather than held from the lock, so a reader who overshoots, thinks
|
||||
* better of it and drags back past centre gets the other action offered
|
||||
* better of it and drags back past center gets the other action offered
|
||||
* instead of the row refusing to move.
|
||||
*/
|
||||
if (d !== dir.current) {
|
||||
@@ -322,7 +322,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
|
||||
*
|
||||
* Native listeners rather than React props because the move handler has to be
|
||||
* able to call `preventDefault`, and React attaches its own passively. Bound
|
||||
* to the scroll container itself so that everything inside it -- a virtualised
|
||||
* to the scroll container itself so that everything inside it -- a virtualized
|
||||
* list included -- comes down with the pull without knowing about it.
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
@@ -542,7 +542,7 @@ export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
|
||||
* moment it can be decided cleanly.
|
||||
* - **It does not start on the toolbar.** Buttons live there.
|
||||
*
|
||||
* The axis lock is the shared one, so it keeps the same bias towards the
|
||||
* The axis lock is the shared one, so it keeps the same bias toward the
|
||||
* vertical: the day grid scrolls through the hours, and a scroll misread as a
|
||||
* swipe throws the reader into another day.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user