Files
ihasmail-inbuxa/web/src/views/settings/ShareDialog.tsx
T
jcoffey-dev 87383440bb German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
2026-08-31 11:06:54 -07:00

156 lines
7.7 KiB
TypeScript

import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar";
import { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
import { t } from "@/lib/i18n";
/* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
Mailbox: [
{ key: "mayReadItems", label: "Read" },
{ key: "mayAddItems", label: "Add" },
{ key: "mayRemoveItems", label: "Remove" },
{ key: "maySetSeen", label: "Mark read" },
{ key: "maySetKeywords", label: "Flag" },
{ key: "mayCreateChild", label: "Create subfolders" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "maySubmit", label: "Send" },
],
Calendar: [
{ key: "mayReadFreeBusy", label: "See free/busy" },
{ key: "mayReadItems", label: "Read events" },
{ key: "mayWriteAll", label: "Edit all" },
{ key: "mayWriteOwn", label: "Edit own" },
{ key: "mayUpdatePrivate", label: "Private props" },
{ key: "mayRSVP", label: "RSVP" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
AddressBook: [
{ key: "mayRead", label: "Read" },
{ key: "mayWrite", label: "Write" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
// Stalwart 0.16.19 returns all six on a node of your own (2026-08-27).
FileNode: [
{ key: "mayRead", label: "Read" },
{ key: "mayAddChildren", label: "Add files" },
{ key: "mayModifyContent", label: "Edit contents" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "mayShare", label: "Share" },
],
};
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
// An editor can fill a folder and change what is in it, but not rename or
// delete the folder they were given -- those stay with whoever shared it.
FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] },
};
/** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
const principals = useContacts((s) => s.principals);
const loadPrincipals = useContacts((s) => s.loadPrincipals);
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
const [pick, setPick] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
void loadPrincipals();
}, [loadPrincipals]);
const available = principals.filter((p) => !rights[p.id]);
const add = (p: Principal, preset: "reader" | "editor") => {
const r: Record<string, boolean> = {};
for (const k of PRESETS[kind][preset]) r[k] = true;
setRights({ ...rights, [p.id]: r });
setPick("");
};
const save = async () => {
setBusy(true);
try {
const accountId =
kind === "Mailbox" ? useMail.getState().accountId
: kind === "Calendar" ? useCalendar.getState().accountId
: kind === "FileNode" ? useFiles.getState().accountId
: useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
toast.success("Sharing updated");
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
if (kind === "FileNode") void useFiles.getState().refresh([id]);
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
returns no principals -- showed nothing but the hint, and an existing
share could not be seen, let alone removed. */}
{!principals.length && (
<p className="hint" style={{ marginBottom: 12 }}>
{t("No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.")}
</p>
)}
{principals.length > 0 && (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">{t("Add a person or group…")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
))}
</select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>{t("Viewer")}</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>{t("Editor")}</button>
</div>
</>
)}
{Object.entries(rights).map(([pid, r]) => {
const p = principals.find((x) => x.id === pid);
return (
<div key={pid} className="card">
<div className="card-head">
<h3><span>{p?.name ?? pid}</span>{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
<div className="row wrap" style={{ marginTop: 8 }}>
{RIGHTS[kind].map((rt) => (
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
<span className="small">{t(rt.label)}</span>
</label>
))}
</div>
</div>
);
})}
{!Object.keys(rights).length && <p className="hint">{t("Not shared with anyone yet.")}</p>}
</Dialog>
);
}