Fill placeholders when a template is inserted

Templates were a fixed subject and body, so anything that changed per
message -- who it is going to, today's date -- had to be typed over
afterwards.

Eight names are recognised: recipientName, recipientFirstName,
recipientEmail, myName, myEmail, subject, date and time. Dates and times
go through datetime.ts rather than toLocaleDateString, so a template
follows the date order and clock the app was already told to use.

Filling happens on insert rather than on send. What a placeholder came to
is then visible in the composer and can be edited, instead of the message
changing between writing it and sending it.

Two things are deliberately left alone. A placeholder that cannot be
answered yet -- a recipient's name on a draft nobody has addressed -- stays
in the body as written, because substituting an empty string produces
"Hi ,", which is wrong rather than visibly unfinished; leaving the name
says which word is still missing and can be typed over. And a name that is
not a placeholder is left as written too, since a body that quietly ate an
unrecognised token would be worse than one that shows it.

Values are escaped on the way into HTML: a display name comes from a
contact card or a typed address and is not trusted markup.
This commit is contained in:
2026-09-01 21:30:18 -07:00
parent 34578ba426
commit c85525f3ed
6 changed files with 239 additions and 3 deletions
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
import { t as translate } from "@/lib/i18n";
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
@@ -37,8 +38,48 @@ export function TemplatesSettings() {
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
</div>
</div>
<PlaceholderReference />
</Dialog>
)}
</div>
);
}
/**
* What each placeholder answers, shown under the body so the names are
* discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather
* than from this map, so a name added to the lib and forgotten here appears
* with no description instead of quietly not appearing at all.
*/
function placeholderHint(name: string): string {
switch (name) {
case "recipientName": return translate("Who the message is addressed to");
case "recipientFirstName": return translate("Their first name alone");
case "recipientEmail": return translate("Their address");
case "myName": return translate("The name on the identity you are sending as");
case "myEmail": return translate("That identity's address");
case "subject": return translate("The subject already on the message");
case "date": return translate("Today, in your date format");
case "time": return translate("Now, on your clock");
default: return "";
}
}
function PlaceholderReference() {
return (
<div className="field">
<label>{translate("Placeholders")}</label>
<div className="placeholder-list">
{PLACEHOLDER_NAMES.map((name) => (
<div key={name} className="placeholder-row">
<code>{`{{${name}}}`}</code>
<span className="hint">{placeholderHint(name)}</span>
</div>
))}
</div>
<p className="hint">
{translate("Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.")}
</p>
</div>
);
}