Compose: spelling and grammar check with LanguageTool

A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by
`make up`) checks what the user writes in the compose window, through
POST /api/spellcheck — the browser never talks to the engine directly.

- Marks are CSS highlights over the text, never written into the message: red
  wavy for spelling, blue for grammar/style. Clicking one shows the message,
  up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore.
  The popup, like the link popup, opens above its anchor when there is no
  room below.
- Checked after a pause in typing; the signature and quoted original are
  skipped; marks on words just edited drop at once; a check overtaken by a
  newer one, by turning checking off, or by closing the window is discarded.
- With "auto", text is checked in chunks of lines, each in its own detected
  language — LanguageTool settles on one language per request, so a mixed
  Polish/English message had every English word flagged as a Polish typo.
- Settings > Appearance: on/off and language (Auto-detect or any of the
  engine's languages); the compose window's More menu overrides it for one
  message. The browser's own spellcheck steps aside while ours runs, and comes
  back when it is off or the server has no engine (503 from the endpoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 23:51:31 +02:00
co-authored by Claude Opus 5.5
parent b157c5c9bb
commit 1f2e539eb2
16 changed files with 1000 additions and 9 deletions
+67 -1
View File
@@ -159,6 +159,21 @@ export function Composer({
const [showToolbar, setShowToolbar] = useState(true);
const [fromOpen, setFromOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
// This message's spell-check language (More menu); null follows Settings.
const [spellLang, setSpellLang] = useState<string | null>(null);
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
// The language list folds away whenever the More menu closes.
useEffect(() => {
if (!moreOpen) setSpellMenuOpen(false);
}, [moreOpen]);
// What the engine can check — and whether this server has one at all.
const spellLanguages = useQuery({
queryKey: ["spellcheck-languages"],
queryFn: api.spellcheckLanguages,
retry: false,
staleTime: 3_600_000,
});
const moreRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLDivElement>(null);
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
@@ -656,7 +671,7 @@ export function Composer({
{t("compose.more")}
</HeaderButton>
{moreOpen && (
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<div className="absolute left-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => {
@@ -676,6 +691,56 @@ export function Composer({
>
{t("compose.insertSignature")}
</button>
{spellLanguages.data && (() => {
// The language in force: this message's choice, else Settings.
const fallback = settingsQuery.data?.spellcheck_enabled
? (settingsQuery.data?.spellcheck_language ?? "auto")
: "off";
const current = spellLang ?? fallback;
const nameOf = (code: string) =>
code === "off"
? t("compose.spellOff")
: code === "auto"
? t("compose.spellAuto")
: (spellLanguages.data.find((l) => l.code === code)?.name ?? code);
const choices = [
{ code: "off", name: t("compose.spellOffMessage") },
{ code: "auto", name: t("compose.spellAuto") },
...spellLanguages.data,
];
return (
<>
<button
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
aria-expanded={spellMenuOpen}
onClick={() => setSpellMenuOpen((v) => !v)}
>
<span className="truncate">
{t("compose.spellMenu")}: {nameOf(current)}
</span>
<span className="shrink-0 text-app-faint">{spellMenuOpen ? "▾" : "›"}</span>
</button>
{spellMenuOpen && (
<div className="max-h-56 overflow-y-auto border-y border-app-border bg-app-surface py-1">
{choices.map((c) => (
<button
key={c.code}
className="flex w-full items-center gap-2 px-3 py-1 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setSpellLang(c.code);
setSpellMenuOpen(false);
setMoreOpen(false);
}}
>
<span className="w-3 shrink-0 text-app-accent">{c.code === current ? "✓" : ""}</span>
<span className="truncate">{c.name}</span>
</button>
))}
</div>
)}
</>
);
})()}
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
@@ -870,6 +935,7 @@ export function Composer({
setBodyHtml(html);
}}
showToolbar={showToolbar}
spellLanguage={spellLang}
// With a height to divide, the body takes what is left and
// scrolls. A floor here would instead push the window past that
// height — and `overflow-hidden` would clip the difference.