Use American English spelling throughout

This commit is contained in:
2026-09-15 11:45:58 -07:00
parent 6ba89696ee
commit d1731efdb9
169 changed files with 786 additions and 795 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ import { AdminNav } from "./admin/AdminNav";
/*
* How far the sidebar edge can be dragged. Below about 228px the module bar
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
* above that. Long folder names are allowed to ellipsise -- narrowing the pane
* above that. Long folder names are allowed to ellipsize -- narrowing the pane
* is asking for that. The ceiling keeps a list and a reading pane beside it on
* an ordinary laptop screen.
*/
@@ -399,7 +399,7 @@ function ThemeToggle() {
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const next = toggleTarget({ palette: settings.palette, mode: settings.mode }, prefersDark);
// Name where it is going, and by the palette when the palette is changing --
// going back to ihasmail's own colours is not the same as "dark mode".
// going back to ihasmail's own colors is not the same as "dark mode".
// The palette never changes now, so the label is only ever the side.
const label = next.mode === "light" ? t("light mode") : t("dark mode");
return (
+1 -1
View File
@@ -270,7 +270,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
<p className="hint">
{self
? t("You can't move your own account into a tenant.")
: t("An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}
: t("An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts toward its limits, and Administrator means administrator of that tenant.")}
</p>
</>
)}
+8 -8
View File
@@ -51,22 +51,22 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
}, [text]);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
void (async () => {
try {
const q = await queryAccounts({ type: "User", text: query, position, limit: PAGE_SIZE });
const accounts = await getAccounts(q.ids);
if (!cancelled) setPage({ accounts, total: q.total });
if (!canceled) setPage({ accounts, total: q.total });
} catch (err) {
if (!cancelled) {
if (!canceled) {
setPage({ accounts: [], total: 0 });
setError(describeDirectoryError(err));
}
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [query, position, reload]);
@@ -85,13 +85,13 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
setLoose(null);
return;
}
let cancelled = false;
let canceled = false;
void getAccounts([selectedId]).then(
([a]) => { if (!cancelled) setLoose(a ?? null); },
() => { if (!cancelled) setLoose(null); },
([a]) => { if (!canceled) setLoose(a ?? null); },
() => { if (!canceled) setLoose(null); },
);
return () => {
cancelled = true;
canceled = true;
};
}, [selectedId, page]);
+5 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/adminDashboard";
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n";
@@ -45,17 +45,17 @@ export function AdminDashboard() {
const key = cards.join(",");
useEffect(() => {
let cancelled = false;
let canceled = false;
const into = <T,>(set: (v: Loaded<T>) => void, work: () => Promise<T>) => {
set(LOADING);
void settle(work()).then((v) => !cancelled && set(v));
void settle(work()).then((v) => !canceled && set(v));
};
if (cards.includes("users")) into(setUsers, () => countObjects("Account"));
if (cards.includes("domains")) into(setDomains, () => countObjects("Domain"));
if (cards.includes("pending")) into(setPending, () => countObjects("QueuedMessage"));
if (cards.includes("received")) into(setMessages, async () => summariseMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
if (cards.includes("received")) into(setMessages, async () => summarizeMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
return () => {
cancelled = true;
canceled = true;
};
// `key` is the card list's contents; the array itself is new every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
+11 -11
View File
@@ -12,7 +12,7 @@ import {
listDkimKeys,
looksLikeDomain,
namesOf,
normaliseDomain,
normalizeDomain,
parseZoneFile,
updateDomain,
type DirectoryDomainFull,
@@ -76,11 +76,11 @@ export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, o
useEffect(() => {
if (!id) return;
let cancelled = false;
let canceled = false;
void (async () => {
try {
const [d] = await getDomains([id], { zoneFile: true });
if (cancelled) return;
if (canceled) return;
if (!d) {
setLoadError(t("This domain no longer exists. Someone may have removed it."));
return;
@@ -91,18 +91,18 @@ export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, o
setCatchAll(d.catchAllAddress ?? "");
setPlus(d.subAddressing?.["@type"] ?? "Enabled");
if (can(perms, "DkimSignature", "Query") && can(perms, "DkimSignature", "Get")) {
void listDkimKeys(id).then((k) => { if (!cancelled) setKeys(k); }, () => { if (!cancelled) setKeys(null); });
void listDkimKeys(id).then((k) => { if (!canceled) setKeys(k); }, () => { if (!canceled) setKeys(null); });
}
const serverId = d.dnsManagement?.["@type"] === "Automatic" ? d.dnsManagement.dnsServerId : undefined;
if (serverId && can(perms, "DnsServer", "Get")) {
void namesOf("DnsServer", [serverId]).then((n) => { if (!cancelled) setProvider(n.get(serverId) ?? null); }, () => {});
void namesOf("DnsServer", [serverId]).then((n) => { if (!canceled) setProvider(n.get(serverId) ?? null); }, () => {});
}
} catch (err) {
if (!cancelled) setLoadError(describeDirectoryError(err, "domain"));
if (!canceled) setLoadError(describeDirectoryError(err, "domain"));
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [id, perms, revision]);
@@ -126,14 +126,14 @@ export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, o
return;
}
const newId = await createDomain({ name, description });
toast.success(t("Added {name}. Its DNS records are ready to copy.", { name: normaliseDomain(name) }));
toast.success(t("Added {name}. Its DNS records are ready to copy.", { name: normalizeDomain(name) }));
onCreated(newId);
return;
}
if (!domain) return;
const patch: Record<string, unknown> = {};
if ((domain.description ?? "") !== description) patch.description = description.trim() || null;
const nextAliases = [...new Set(aliases.map(normaliseDomain).filter(Boolean))];
const nextAliases = [...new Set(aliases.map(normalizeDomain).filter(Boolean))];
if (JSON.stringify(Object.keys(domain.aliases ?? {}).sort()) !== JSON.stringify([...nextAliases].sort())) {
patch.aliases = Object.fromEntries(nextAliases.map((a) => [a, true]));
}
@@ -304,7 +304,7 @@ export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, o
function AliasList({ aliases, setAliases, editable }: { aliases: string[]; setAliases: (a: string[]) => void; editable: boolean }) {
const [value, setValue] = useState("");
const add = () => {
const name = normaliseDomain(value);
const name = normalizeDomain(value);
if (!looksLikeDomain(name) || aliases.includes(name)) return;
setAliases([...aliases, name]);
setValue("");
@@ -376,7 +376,7 @@ function RemoveDomain({ domain, accountCount, keys, canRemoveKeys, onDeleted }:
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || normaliseDomain(typed) !== domain.name}
disabled={busy || normalizeDomain(typed) !== domain.name}
onClick={async () => {
setBusy(true);
setError(null);
+6 -6
View File
@@ -32,27 +32,27 @@ export function DomainsAdmin({ selectedId }: { selectedId?: string }) {
}, [text]);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
void (async () => {
try {
const q = await queryDomains({ text: query, position, limit: PAGE_SIZE });
const domains = await getDomains(q.ids);
if (cancelled) return;
if (canceled) return;
setPage({ domains, total: q.total });
// Both are extras on top of the list, and each needs a permission of its own.
if (can(perms, "Account", "Query")) void countAccounts(domains.map((d) => d.id)).then((c) => { if (!cancelled) setCounts(c); });
if (can(perms, "Account", "Query")) void countAccounts(domains.map((d) => d.id)).then((c) => { if (!canceled) setCounts(c); });
const tenantIds = [...new Set(domains.map((d) => d.memberTenantId).filter((x): x is string => Boolean(x)))];
if (tenantIds.length && can(perms, "Tenant", "Get")) void namesOf("Tenant", tenantIds).then((n) => { if (!cancelled) setTenants(n); }, () => {});
if (tenantIds.length && can(perms, "Tenant", "Get")) void namesOf("Tenant", tenantIds).then((n) => { if (!canceled) setTenants(n); }, () => {});
} catch (err) {
if (!cancelled) {
if (!canceled) {
setPage({ domains: [], total: 0 });
setError(describeDirectoryError(err, "domain"));
}
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [query, position, reload, perms]);
+8 -8
View File
@@ -78,18 +78,18 @@ export function GroupSheet({ group, ctx, onClose, onChanged, onCreated, onDelete
useEffect(() => {
if (!group) return;
let cancelled = false;
let canceled = false;
setMembersError(null);
listMembers(group.id).then(
(m) => !cancelled && setMembers(m),
(m) => !canceled && setMembers(m),
(err) => {
if (cancelled) return;
if (canceled) return;
setMembers({ members: [], total: 0 });
setMembersError(describeDirectoryError(err, "group"));
},
);
return () => {
cancelled = true;
canceled = true;
};
}, [group, membersRevision]);
@@ -348,15 +348,15 @@ function AddMember({ ctx, exclude, busy, onAdd }: { ctx: DirectoryContext; exclu
setFound(null);
return;
}
let cancelled = false;
let canceled = false;
const id = window.setTimeout(() => {
searchUsers(needle).then(
(list) => !cancelled && setFound(list),
() => !cancelled && setFound([]),
(list) => !canceled && setFound(list),
() => !canceled && setFound([]),
);
}, 250);
return () => {
cancelled = true;
canceled = true;
window.clearTimeout(id);
};
}, [text]);
+9 -9
View File
@@ -45,24 +45,24 @@ export function GroupsAdmin({ selectedId }: { selectedId?: string }) {
}, [text]);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
void (async () => {
try {
const q = await queryGroups({ text: query, position, limit: PAGE_SIZE });
const groups = await getGroups(q.ids);
if (cancelled) return;
if (canceled) return;
setPage({ groups, total: q.total });
void countMembers(groups.map((g) => g.id)).then((c) => !cancelled && setCounts(c));
void countMembers(groups.map((g) => g.id)).then((c) => !canceled && setCounts(c));
} catch (err) {
if (!cancelled) {
if (!canceled) {
setPage({ groups: [], total: 0 });
setError(describeDirectoryError(err, "group"));
}
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [query, position, reload]);
@@ -76,13 +76,13 @@ export function GroupsAdmin({ selectedId }: { selectedId?: string }) {
setLoose(null);
return;
}
let cancelled = false;
let canceled = false;
void getGroups([selectedId]).then(
([g]) => { if (!cancelled) setLoose(g ?? null); },
() => { if (!cancelled) setLoose(null); },
([g]) => { if (!canceled) setLoose(g ?? null); },
() => { if (!canceled) setLoose(null); },
);
return () => {
cancelled = true;
canceled = true;
};
}, [selectedId, page]);
+8 -8
View File
@@ -42,22 +42,22 @@ export function ListsAdmin({ selectedId }: { selectedId?: string }) {
}, [text]);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
void (async () => {
try {
const q = await queryLists({ text: query, position, limit: PAGE_SIZE });
const lists = await getLists(q.ids);
if (!cancelled) setPage({ lists, total: q.total });
if (!canceled) setPage({ lists, total: q.total });
} catch (err) {
if (!cancelled) {
if (!canceled) {
setPage({ lists: [], total: 0 });
setError(describeDirectoryError(err, "list"));
}
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [query, position, reload]);
@@ -70,13 +70,13 @@ export function ListsAdmin({ selectedId }: { selectedId?: string }) {
setLoose(null);
return;
}
let cancelled = false;
let canceled = false;
void getLists([selectedId]).then(
([l]) => { if (!cancelled) setLoose(l ?? null); },
() => { if (!cancelled) setLoose(null); },
([l]) => { if (!canceled) setLoose(l ?? null); },
() => { if (!canceled) setLoose(null); },
);
return () => {
cancelled = true;
canceled = true;
};
}, [selectedId, page]);
+9 -9
View File
@@ -29,31 +29,31 @@ export function RolesAdmin({ selectedId }: { selectedId?: string }) {
const [reload, setReload] = useState(0);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
listAllRoles().then(
(list) => !cancelled && setRoles(list),
(list) => !canceled && setRoles(list),
(err) => {
if (cancelled) return;
if (canceled) return;
setRoles([]);
setError(describeDirectoryError(err, "role"));
},
);
void loadRoleDefaults().then((d) => !cancelled && setDefaults(d));
void loadRoleDefaults().then((d) => !canceled && setDefaults(d));
return () => {
cancelled = true;
canceled = true;
};
}, [reload]);
// The permission list changes only when Stalwart is upgraded; once per visit is plenty.
useEffect(() => {
let cancelled = false;
let canceled = false;
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
([list, catalog]) => !cancelled && setEntries(describePermissions(list, catalog, t("General"))),
(err) => !cancelled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))),
(err) => !canceled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
);
return () => {
cancelled = true;
canceled = true;
};
}, []);
+7 -7
View File
@@ -97,10 +97,10 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
useEffect(() => {
if (!tenant) return;
let cancelled = false;
void countTenantMembers(tenant.id).then((c) => !cancelled && setCounts(c));
let canceled = false;
void countTenantMembers(tenant.id).then((c) => !canceled && setCounts(c));
return () => {
cancelled = true;
canceled = true;
};
}, [tenant, revision]);
@@ -260,21 +260,21 @@ function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTena
const [revision, setRevision] = useState(0);
useEffect(() => {
let cancelled = false;
let canceled = false;
tenantDomains(tenant.id).then(
(s) => {
if (cancelled) return;
if (canceled) return;
setState(s);
setPick(s.unassigned[0]?.id ?? "");
},
(err) => {
if (cancelled) return;
if (canceled) return;
setState({ inTenant: [], unassigned: [] });
setError(describeDirectoryError(err, "domain"));
},
);
return () => {
cancelled = true;
canceled = true;
};
}, [tenant, revision]);
+11 -11
View File
@@ -15,7 +15,7 @@ import { TenantSheet } from "./TenantSheet";
const PAGE_SIZE = 50;
/**
* Tenants: separate organisations on one server, each with its own people,
* Tenants: separate organizations on one server, each with its own people,
* domains and limits.
*
* The section is offered to whoever may read tenants. On a server that does not
@@ -35,7 +35,7 @@ export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
<div className="admin-head">
<div className="grow">
<h1>{t("Tenants")}</h1>
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
<p className="lead">{t("Separate organizations on one server, each with its own people, domains and limits.")}</p>
</div>
</div>
<EnterpriseNotice warn />
@@ -71,22 +71,22 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
}, [text]);
useEffect(() => {
let cancelled = false;
let canceled = false;
setError(null);
void (async () => {
try {
const q = await queryTenants({ text: query, position, limit: PAGE_SIZE });
const tenants = await getTenants(q.ids);
if (!cancelled) setPage({ tenants, total: q.total });
if (!canceled) setPage({ tenants, total: q.total });
} catch (err) {
if (!cancelled) {
if (!canceled) {
setPage({ tenants: [], total: 0 });
setError(describeDirectoryError(err, "tenant"));
}
}
})();
return () => {
cancelled = true;
canceled = true;
};
}, [query, position, reload]);
@@ -99,13 +99,13 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
setLoose(null);
return;
}
let cancelled = false;
let canceled = false;
void getTenants([selectedId]).then(
([x]) => { if (!cancelled) setLoose(x ?? null); },
() => { if (!cancelled) setLoose(null); },
([x]) => { if (!canceled) setLoose(x ?? null); },
() => { if (!canceled) setLoose(null); },
);
return () => {
cancelled = true;
canceled = true;
};
}, [selectedId, page]);
@@ -118,7 +118,7 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
<div className="admin-head">
<div className="grow">
<h1>{t("Tenants")}</h1>
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
<p className="lead">{t("Separate organizations on one server, each with its own people, domains and limits.")}</p>
</div>
{can(perms, "Tenant", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/tenants/new")}>
@@ -24,7 +24,7 @@ interface Props {
onCreate: (start: Date, end: Date, allDay: boolean) => void;
}
/** Resolve the display colour of an event: explicit colour → category colour → calendar colour. */
/** Resolve the display color of an event: explicit color → category color → calendar color. */
export function eventColor(ev: CalendarEvent, calendarColor: string | null | undefined, categories: Array<{ name: string; color: string }>): string {
if (ev.color) return ev.color;
const cat = categoryOf(ev, categories);
@@ -77,10 +77,10 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? t("Colour updated") : t("Custom colour removed"));
const setColor = (color: string | null) => void patch({ color }, color ? t("Color updated") : t("Custom color removed"));
const setCategory = (cat: { name: string; color: string } | null) => {
const categoriesPatch = cat ? { [cat.name]: true } : null;
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? t("Categorised as {name}", { name: cat.name }) : t("Category cleared"));
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? t("Categorized as {name}", { name: cat.name }) : t("Category cleared"));
};
const duplicate = async () => {
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
@@ -123,16 +123,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
<MenuItem icon={<X size={16} />} label={t("No category")} disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label={t("Manage categories…")} onClick={() => { onClose(); navigate("/settings/calendar"); }} />
{/*
A colour is what a category already carries, so a second way to set
A color is what a category already carries, so a second way to set
one just made two things that could disagree. Picking a category is
now the only way to colour an event here.
now the only way to color an event here.
Clearing one stays, though, and only when there is one to clear: an
event that already has an explicit colour — set before this, or by
event that already has an explicit color — set before this, or by
another client — would otherwise ignore its category for ever with
nothing on the menu to say why.
*/}
{ev.color && <MenuItem icon={<Palette size={16} />} label={t("Clear custom colour")} onClick={() => { onClose(); setColor(null); }} />}
{ev.color && <MenuItem icon={<Palette size={16} />} label={t("Clear custom color")} onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} onClick={() => void del()} />
</>
+1 -1
View File
@@ -26,7 +26,7 @@ type View = "month" | "week" | "day" | "agenda";
* They used to be `v[0].toUpperCase() + v.slice(1)`, which is correct English
* and untranslatable in every other language: the extractor cannot see a
* string that is computed, so the four buttons stayed English even in a
* catalogue that had all four words. Called rather than looked up, because a
* catalog that had all four words. Called rather than looked up, because a
* module-level object would capture the labels for whichever language loaded
* first.
*/
+5 -5
View File
@@ -181,7 +181,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
/*
* Everyone the event concerns, you first. Scheduling around the other people
* and not around yourself is how two things end up at the same time, and the
* organiser's own calendar was the one row the panel never showed.
* organizer's own calendar was the one row the panel never showed.
*/
const people = useMemo(() => {
const seen = new Set<string>();
@@ -212,7 +212,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return;
}
let cancelled = false;
let canceled = false;
(async () => {
const out: Record<string, BusyPeriod[] | null> = {};
for (const person of people) {
@@ -235,10 +235,10 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
out[person.email] = null;
}
}
if (!cancelled) setFb(out);
if (!canceled) setFb(out);
})();
return () => {
cancelled = true;
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [people.map((p) => p.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded, selfPrincipalId]);
@@ -538,7 +538,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{showMore && (
<div className="mt-8">
<div className="field-row">
<div className="field"><label>{translate("Status")}</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">{translate("Confirmed")}</option><option value="tentative">{translate("Tentative")}</option><option value="cancelled">{translate("Cancelled")}</option></select></div>
<div className="field"><label>{translate("Status")}</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">{translate("Confirmed")}</option><option value="tentative">{translate("Tentative")}</option><option value="cancelled">{translate("Canceled")}</option></select></div>
<div className="field"><label>{translate("Show as")}</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">{translate("Busy")}</option><option value="free">{translate("Free")}</option></select></div>
{!oneDate && <div className="field"><label>{translate("Visibility")}</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">{translate("Default")}</option><option value="private">{translate("Private")}</option><option value="secret">{translate("Secret")}</option></select></div>}
</div>
+1 -1
View File
@@ -80,7 +80,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
<div className="ev-line"><CalIcon size={15} /><span>{`${inst.calendar?.name ?? "Calendar"}${ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}${ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}${ev.freeBusyStatus === "free" ? " · shown as free" : ""}`}</span></div>
<div className="ev-line"><CalIcon size={15} /><span>{`${inst.calendar?.name ?? "Calendar"}${ev.status === "cancelled" ? " · canceled" : ev.status === "tentative" ? " · tentative" : ""}${ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}${ev.freeBusyStatus === "free" ? " · shown as free" : ""}`}</span></div>
{participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title={t("Email everyone")} onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
+1 -1
View File
@@ -123,7 +123,7 @@ export function Composer({ draft }: { draft: Draft }) {
const names = outside.slice(0, 5).map((a) => a.email).join(", ");
const rest = outside.length > 5 ? translate(" and {count} more", { count: String(outside.length - 5) }) : "";
const ok = await confirmDialog({
title: translate("Send outside your organisation?"),
title: translate("Send outside your organization?"),
message: translate("This goes to {recipients}{rest}.", { recipients: names, rest }),
confirmLabel: translate("Send anyway"),
});
@@ -62,7 +62,7 @@ describe("ComposerDock with a full-screen composer", () => {
expect(dock().classList.contains("has-maximized")).toBe(false);
});
it("does not count a full-screen composer that has since been minimised", () => {
it("does not count a full-screen composer that has since been minimized", () => {
setWidth(1300);
render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
expect(dock().classList.contains("has-maximized")).toBe(false);
+1 -1
View File
@@ -62,7 +62,7 @@ export function FilesTree() {
const [rootDrop, setRootDrop] = useState(false);
const menu = useMenu();
/* Shared with the list pane: a drag starting in one has to be recognised by
/* Shared with the list pane: a drag starting in one has to be recognized by
the other. See the note on `draggingId` in the store. */
const draggingIds = useFiles((s) => s.draggingIds);
const setDragging = useFiles((s) => s.setDragging);
+1 -1
View File
@@ -37,7 +37,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const [editTarget, setEditTarget] = useState<{ id: Id; blobId: Id | null } | null>(null);
const [startInEdit, setStartInEdit] = useState(false);
/* Shared with the sidebar tree, so a row dragged onto a folder there is
recognised. See the note on `draggingId` in the store. */
recognized. See the note on `draggingId` in the store. */
const draggingIds = files.draggingIds;
const setDragging = files.setDragging;
const inputRef = useRef<HTMLInputElement>(null);
+5 -5
View File
@@ -17,18 +17,18 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
useEffect(() => {
if (!cal.available || !part.blobId) return;
let cancelled = false;
let canceled = false;
cal
.parseIcs(part.blobId)
.then(async (evs) => {
if (cancelled) return;
if (canceled) return;
setEvents(evs);
const first = evs[0];
if (first?.uid) setExisting(await cal.findByUid(first.uid));
})
.catch((err) => !cancelled && setError((err as Error).message));
.catch((err) => !canceled && setError((err as Error).message));
return () => {
cancelled = true;
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.blobId, cal.available]);
@@ -80,7 +80,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
}
};
const title = method === "CANCEL" ? "Cancelled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
const title = method === "CANCEL" ? "Canceled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
return (
<div className="invite-card">
+1 -1
View File
@@ -101,7 +101,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
}, [listQuery, query, mailboxesLoaded]);
// Nothing moves a message out of Scheduled when its hold expires, so settle
// the folder up on the way in: sent messages to Sent, cancelled ones back to
// the folder up on the way in: sent messages to Sent, canceled ones back to
// Drafts, and refresh what is still waiting.
useEffect(() => {
if (mailboxesLoaded && mailboxId && mailboxId === scheduledId) void reconcile();
+6 -6
View File
@@ -290,9 +290,9 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled;
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
// A chosen colour tints the icon only; the label keeps the tree's own
// contrast, which a dozen arbitrary colours would not reliably give it.
// Subscribed, not read once: picking a colour has to repaint the row.
// A chosen color tints the icon only; the label keeps the tree's own
// contrast, which a dozen arbitrary colors would not reliably give it.
// Subscribed, not read once: picking a color has to repaint the row.
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
const onDragOver = (e: DragEvent) => {
@@ -442,7 +442,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
return n;
});
const rename = async () => {
const name = await // The server's own name, never the localised one: this box writes
const name = await // The server's own name, never the localized one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("Rename folder"), defaultValue: m.name });
if (!name?.trim() || name.trim() === m.name) return;
@@ -497,7 +497,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
reason this entry survives at all. */}
{shared && <MenuItem icon={<Share2 size={16} />} label={t("Stop sharing")} onClick={onShare} />}
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> {t("Colour")}</span></MenuTitle>
<MenuTitle><span className="row gap-4"><Palette size={12} /> {t("Color")}</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button
@@ -509,7 +509,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
/>
))}
</div>
{color && <MenuItem icon={<X size={16} />} label={t("Use the default colour")} onClick={() => setColor(null)} />}
{color && <MenuItem icon={<X size={16} />} label={t("Use the default color")} onClick={() => setColor(null)} />}
<MenuSep />
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
<MenuItem icon={<Trash2 size={16} />} label={t("Delete folder")} onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
+1 -1
View File
@@ -612,7 +612,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
/*
* How far this row has been dragged from home, and whether it is currently
* animating back. Kept here rather than in the list so that a moving finger
* re-renders one row instead of the whole virtualised list; the list is told
* re-renders one row instead of the whole virtualized list; the list is told
* only the three things the strip behind the row needs -- which row, which
* way, and whether letting go now would fire.
*/
+5 -5
View File
@@ -60,12 +60,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
/*
* Following a link out of a message, when the reader has asked to be asked.
*
* The click is cancelled and the navigation re-issued after the answer,
* The click is canceled and the navigation re-issued after the answer,
* because there is no way to hold a real navigation open across a dialog.
* `window.open` runs in the continuation of the dialog's own click, which is
* still the user gesture the popup blocker wants to see.
*
* Both message bodies go through here -- the sanitised HTML one and the
* Both message bodies go through here -- the sanitized HTML one and the
* plain-text one -- because a link in a plain-text mail is linkified by us
* and is exactly as capable of pointing somewhere else as one the sender
* marked up.
@@ -167,7 +167,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
* the reader has asked for the theme over that too.
*
* `forced` is the second switch and is narrower than `themed`: it only turns
* on for mail that actually declares colours, so plain mail is themed the
* on for mail that actually declares colors, so plain mail is themed the
* gentle way and never pays for the override rules.
*/
const declaresColors = useMemo(
@@ -411,7 +411,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
onClick={async () => {
try {
await cancelScheduled(e.id);
toast.success(translate("Send cancelled — the message is back in Drafts"));
toast.success(translate("Send canceled — the message is back in Drafts"));
} catch (err) {
toast.error(translate("Could not cancel: {error}", { error: (err as Error).message }));
}
@@ -427,7 +427,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
<ShieldAlert size={16} />
<span className="grow">
{tNode("This message came from {domain}, which is outside your organisation.", {
{tNode("This message came from {domain}, which is outside your organization.", {
domain: <strong className="notranslate" translate="no">{domainOf(from?.email ?? "")}</strong>,
})}
</span>
@@ -12,7 +12,7 @@ import type { SignatureReport } from "@/lib/smime/verify";
/*
* The wording is the feature here, so it is worth asserting rather than
* eyeballing. The rule this pins down: ihasmail has no certificate authority to
* ask, so a signature that merely verifies against the certificate travelling
* ask, so a signature that merely verifies against the certificate traveling
* beside it must never be dressed as an endorsement. "Verified" full stop is
* the word that would be a lie, and the tone must not be the reassuring one
* until a previous sighting actually corroborates the signer.
@@ -71,7 +71,7 @@ describe("what the signature banner says", () => {
it("does not congratulate a signer it has never seen before", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "first-seen", warnings: [] }));
expect(host.textContent).toContain("seen here for the first time");
// Grey, not green: an unknown certificate that verifies against itself has
// Gray, not green: an unknown certificate that verifies against itself has
// established nothing worth a tick.
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
expect(host.textContent).not.toMatch(/\bverified\b/i);
@@ -7,7 +7,7 @@ import { t as translate, tNode } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
/**
* A swatch for each palette, drawn from the colours that palette actually
* A swatch for each palette, drawn from the colors that palette actually
* paints, so a card looks like what picking it does. Kept as data rather than
* inline ternaries so a sixth palette does not mean editing a conditional in
* three places.
@@ -107,20 +107,20 @@ export function AppearanceSettings() {
})}
</div>
<p className="hint" style={{ marginTop: 10 }}>
{translate("Palettes named after another project are that project's work, used under its own license; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.")}
{translate("Palettes named after another project are that project's work, used under its own license; the shades between their published colors are derived, and every one is checked for contrast. The accent color below still applies over any of them.")}
</p>
<Switch
checked={s.themeMessageBody}
onChange={(v) => update({ themeMessageBody: v })}
label={translate("Apply the theme to messages too")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colors of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
/>
<Switch
checked={s.themeStyledMessages}
disabled={!s.themeMessageBody}
onChange={(v) => update({ themeStyledMessages: v })}
label={translate("Apply it even to mail that styles itself")}
hint={translate("Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.")}
hint={translate("Most marketing and receipt mail sets a color somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colors: backgrounds they laid the message on are dropped, while buttons and colored banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.")}
/>
<h2>{translate("Accent color")}</h2>
@@ -209,7 +209,7 @@ export function AppearanceSettings() {
</div>
</div>
{/*
Said once, where it is relevant, rather than greying the pickers out on
Said once, where it is relevant, rather than graying the pickers out on
a desktop: the settings are real and worth setting here for the phone
that will read them, and a disabled control invites a hunt for whatever
would enable it.
+1 -1
View File
@@ -47,7 +47,7 @@ export function CalendarSettings() {
</select>
</div>
</div>
<h2>{t("Colour categories")}</h2>
<h2>{t("Color categories")}</h2>
<p className="hint">{t("Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.")}</p>
{s.eventCategories.map((c, i) => (
<div key={c.name} className="card">
+3 -3
View File
@@ -12,12 +12,12 @@ import { mailboxDisplayPath } from "@/lib/mailboxName";
/*
* Roles a folder can be given here.
*
* These are the three that ihasmail's own behaviour depends on and that
* These are the three that ihasmail's own behavior depends on and that
* Stalwart will let move. Inbox, Junk and Trash are absent on purpose: 0.16.20
* refuses them outright -- "You are not allowed to change the role of Inbox,
* Junk or Trash folders" -- so offering them would only produce an error.
*
* `label` rather than a bare string so the catalogue sees them: they are
* `label` rather than a bare string so the catalog sees them: they are
* translated where they render.
*/
const SETTABLE_ROLES: { value: Exclude<MailboxRole, null>; label: string }[] = [
@@ -110,7 +110,7 @@ export function FoldersSettings() {
<td>{m.unreadEmails.toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localised one: this box writes
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localized one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? t("Hide") : t("Show")} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
+1 -1
View File
@@ -25,7 +25,7 @@ export function LabelsSettings() {
return (
<div>
<h1>{t("Labels")}</h1>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colours and nesting are ihasmail\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.")}</p>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are ihasmail\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.")}</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
+2 -2
View File
@@ -20,7 +20,7 @@ import { isEnforced } from "@/lib/settingsPolicy";
* two similar words next to each other in a nav is how a menu becomes
* something people hunt through: that section is credentials and access --
* password, two-factor, app passwords, live sessions. This one is how the app
* behaves towards the reader and towards senders.
* behaves toward the reader and toward senders.
*/
export function PrivacySettings() {
const s = useSettings((st) => st.settings);
@@ -155,7 +155,7 @@ export function PrivacySettings() {
/**
* A list of domains, added one at a time and removed by their chip.
*
* Typed entries are normalised on the way in -- a leading `@`, stray case, a
* Typed entries are normalized on the way in -- a leading `@`, stray case, a
* whole address pasted instead of a domain -- because the thing being compared
* against is a hostname, and a list holding "@Example.com " silently matches
* nothing at all.
+1 -1
View File
@@ -23,7 +23,7 @@ export function ShortcutsSettings() {
<div key={group}>
{/* Group names and descriptions are registered in English at the
call sites -- see views/Shortcuts.tsx -- because the binding
table is data, not markup, and the English is the catalogue
table is data, not markup, and the English is the catalog
key. Translating at render keeps the registration simple and
means a binding added anywhere is translatable without the
registrar knowing about i18n. */}
@@ -133,7 +133,7 @@ describe("Privacy & safety", () => {
expect(host.textContent).toContain("Open links to these domains without asking");
});
it("normalises a typed domain, so the list holds something that can match", async () => {
it("normalizes a typed domain, so the list holds something that can match", async () => {
await act(async () => {
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, externalLinkWarning: true } });
});