Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
This commit is contained in:
@@ -21,6 +21,7 @@ import { toast } from "@/ui/toast";
|
||||
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
|
||||
import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
export function Composer({ draft }: { draft: Draft }) {
|
||||
const update = useCompose((s) => s.update);
|
||||
@@ -149,27 +150,27 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<div className="composer minimized" onClick={() => focus(key)}>
|
||||
<div className="composer-head">
|
||||
<span className="title">{title}</span>
|
||||
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label={translate("Restore")} onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label={translate("Close")} onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
|
||||
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label={translate("Compose message")}>
|
||||
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
|
||||
<span className="title">{title}</span>
|
||||
<span className="status">{status}</span>
|
||||
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={translate("Minimize")} title={translate("Minimize")} onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
|
||||
<button className="icon-btn sm" aria-label={translate("Close")} title={translate("Save & close (Esc)")} onClick={() => void close(key)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="composer-body">
|
||||
<div className="composer-fields">
|
||||
{identities.length > 1 && (
|
||||
<div className="composer-field">
|
||||
<label>From</label>
|
||||
<label>{translate("From")}</label>
|
||||
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
|
||||
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
|
||||
</select>
|
||||
@@ -179,53 +180,53 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<label htmlFor={`${key}-to`}>
|
||||
{/* Opens the address books. Autocomplete only helps someone who
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
opens it too, for anyone who tries that first. */}
|
||||
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
<button type="button" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")} aria-label={translate("Choose from address books")}><BookUser size={15} /></button>
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>{translate("Cc")}</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>{translate("Bcc")}</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title={translate("Set a Reply-To address")}>{translate("Reply-To")}</button>}
|
||||
</span>
|
||||
</div>
|
||||
{d.showReplyTo && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
|
||||
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
|
||||
<label htmlFor={`${key}-rt`} title={translate("Replies will go to this address instead of the From address")}>{translate("Reply-To")}</label>
|
||||
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder={translate("Replies go to…")} />
|
||||
</div>
|
||||
)}
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>Cc</label>
|
||||
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>Bcc</label>
|
||||
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
|
||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
|
||||
<label htmlFor={`${key}-subj`} className="sr-only">{translate("Subject")}</label>
|
||||
<input id={`${key}-subj`} className="plain" placeholder={translate("Subject")} value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} />
|
||||
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title={translate("Read receipt requested")}><CheckCheck size={12} /></span>}
|
||||
{d.sendAt !== null && (
|
||||
<button type="button" className="tag" style={{ background: "var(--accent)" }} title="Scheduled — click to clear the schedule" onClick={() => patch({ sendAt: null })}>
|
||||
<button type="button" className="tag" style={{ background: "var(--accent)" }} title={translate("Scheduled — click to clear the schedule")} onClick={() => patch({ sendAt: null })}>
|
||||
<Clock size={12} /> {formatScheduleTime(new Date(d.sendAt))} <X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{d.format === "html" ? (
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
|
||||
) : (
|
||||
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
|
||||
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder={translate("Write your message…")} spellCheck={settings.spellcheck} />
|
||||
)}
|
||||
{d.attachments.some((a) => !a.inline) && (
|
||||
<div className="composer-attachments">
|
||||
@@ -236,7 +237,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<span className="att-name">{a.name}</span>
|
||||
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
|
||||
</span>
|
||||
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
|
||||
<button className="icon-btn xs" aria-label={translate("Remove attachment")} onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
|
||||
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
|
||||
</div>
|
||||
))}
|
||||
@@ -247,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
|
||||
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button>
|
||||
</span>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
|
||||
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
|
||||
@@ -273,32 +274,32 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||
)}
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
{filesAvailable && <button className="icon-btn" title="Attach from Files" onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
|
||||
<button className="icon-btn" title={translate("Attach files")} onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
{filesAvailable && <button className="icon-btn" title={translate("Attach from Files")} onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title={translate("Formatting options")} onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title={translate("Insert template")} onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
|
||||
<MenuTitle>Templates</MenuTitle>
|
||||
<MenuTitle>{translate("Templates")}</MenuTitle>
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||
</Popover>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
|
||||
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
|
||||
<MenuSep />
|
||||
<MenuTitle>Priority</MenuTitle>
|
||||
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuTitle>{translate("Priority")}</MenuTitle>
|
||||
<MenuItem label={translate("High")} checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title={translate("Large attachments may be rejected by some servers")}><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFiles } from "@/store/files";
|
||||
import type { AttachableFile } from "@/store/compose";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Pick something already in Files to attach.
|
||||
@@ -51,11 +52,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
title="Attach from Files"
|
||||
title={t("Attach from Files")}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={close}>Cancel</button>
|
||||
<button className="btn" onClick={close}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!chosen.length}
|
||||
@@ -95,7 +96,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
||||
{files.loading && !nodes.length ? (
|
||||
<Spinner />
|
||||
) : !nodes.length ? (
|
||||
<p className="hint">This folder is empty.</p>
|
||||
<p className="hint">{t("This folder is empty.")}</p>
|
||||
) : (
|
||||
nodes.map((n) =>
|
||||
n.nodeType === "directory" ? (
|
||||
@@ -131,7 +132,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
||||
// Blobs belong to the account holding them, so one from a share has to
|
||||
// be copied into yours before a draft can reference it. Worth saying,
|
||||
// because it is the difference between instant and a wait.
|
||||
<p className="hint" style={{ marginTop: 10 }}>Shared files are copied to your account when attached.</p>
|
||||
<p className="hint" style={{ marginTop: 10 }}>{t("Shared files are copied to your account when attached.")}</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useContacts } from "@/store/contacts";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
||||
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export type Field = "to" | "cc" | "bcc";
|
||||
|
||||
@@ -109,13 +110,13 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Choose recipients"
|
||||
title={t("Choose recipients")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
|
||||
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>{t("Bcc")}</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>{t("Cc")}</button>
|
||||
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
||||
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
||||
</button>
|
||||
@@ -129,14 +130,14 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
||||
<input
|
||||
className="grow"
|
||||
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
||||
placeholder="Search names and addresses"
|
||||
placeholder={t("Search names and addresses")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
|
||||
<option value="all">All address books</option>
|
||||
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label={t("Address book")}>
|
||||
<option value="all">{t("All address books")}</option>
|
||||
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
{subscribed.map((b) => (
|
||||
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
|
||||
@@ -149,7 +150,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
||||
{chosen.length > 0 && (
|
||||
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||
{chosen.map((r) => (
|
||||
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
|
||||
<button key={r.key} className="chip" onClick={() => toggle(r)} title={t("Remove")}>
|
||||
{r.name ?? r.email} <X size={12} />
|
||||
</button>
|
||||
))}
|
||||
@@ -158,7 +159,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
|
||||
|
||||
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
||||
{contacts.loading && !rows.length ? (
|
||||
<Spinner label="Loading contacts…" />
|
||||
<Spinner label={t("Loading contacts…")} />
|
||||
) : !rows.length ? (
|
||||
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSta
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
export interface RichEditorHandle {
|
||||
focus(): void;
|
||||
@@ -218,46 +219,46 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Message body"
|
||||
aria-label={translate("Message body")}
|
||||
/>
|
||||
{showToolbar && (
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
||||
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
|
||||
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Redo")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
||||
<option value="" disabled>Size</option>
|
||||
<option value="1">Small</option>
|
||||
<option value="3">Normal</option>
|
||||
<option value="5">Large</option>
|
||||
<option value="7">Huge</option>
|
||||
<select title={translate("Font size")} onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
||||
<option value="" disabled>{translate("Size")}</option>
|
||||
<option value="1">{translate("Small")}</option>
|
||||
<option value="3">{translate("Normal")}</option>
|
||||
<option value="5">{translate("Large")}</option>
|
||||
<option value="7">{translate("Huge")}</option>
|
||||
</select>
|
||||
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Bold (Ctrl+B)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Italic (Ctrl+I)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Underline (Ctrl+U)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Strikethrough")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Text color")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Highlight")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Align left")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Center")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Align right")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Bulleted list")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Numbered list")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Decrease indent")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Increase indent")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Quote")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Code block")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Normal text")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
|
||||
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
|
||||
<button type="button" className="icon-btn" title={translate("Insert link (Ctrl+K)")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
|
||||
<label className="icon-btn" title={translate("Insert image")} onMouseDown={saveRange}>
|
||||
<ImageIcon size={16} />
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Emoji")} onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
|
||||
<button type="button" className="icon-btn" title={translate("Remove formatting")} onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
)}
|
||||
@@ -280,8 +281,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
</Popover>
|
||||
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
||||
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
||||
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
||||
<button type="submit" className="btn btn-sm btn-primary">Link</button>
|
||||
<input className="input sm" autoFocus placeholder={translate("https://…")} value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
||||
<button type="submit" className="btn btn-sm btn-primary">{translate("Link")}</button>
|
||||
</form>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DateTimeField } from "@/ui/datefield";
|
||||
import { MenuItem, MenuSep, MenuTitle } from "@/ui/popover";
|
||||
import { describeSpan, formatScheduleTime, schedulePresets, scheduleError } from "@/lib/schedule";
|
||||
import { toInputDateTime, fromInputDateTime, roundToNext } from "@/lib/dates";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* The quick picks that hang off the composer's send menu. Anything the server
|
||||
@@ -15,11 +16,11 @@ export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number;
|
||||
return (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Schedule send</MenuTitle>
|
||||
<MenuTitle>{t("Schedule send")}</MenuTitle>
|
||||
{presets.map((p) => (
|
||||
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
|
||||
))}
|
||||
<MenuItem icon={<Clock size={16} />} label="Pick date and time…" onClick={onCustom} />
|
||||
<MenuItem icon={<Clock size={16} />} label={t("Pick date and time…")} onClick={onCustom} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,18 +41,18 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Schedule send"
|
||||
title={t("Schedule send")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>Schedule send</button>
|
||||
<button className="btn" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={Boolean(error)} onClick={() => onPick(at)}>{t("Schedule send")}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="schedule-at">Send at</label>
|
||||
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label="Date and time to send" />
|
||||
<label htmlFor="schedule-at">{t("Send at")}</label>
|
||||
<DateTimeField id="schedule-at" value={value} onChange={setValue} aria-label={t("Date and time to send")} />
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
||||
|
||||
Reference in New Issue
Block a user