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.
156 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|