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
+3
View File
@@ -18,3 +18,6 @@ LOG_LEVEL=DEBUG
RSPAMD_URL=http://127.0.0.1:11333 RSPAMD_URL=http://127.0.0.1:11333
RSPAMD_CONTROLLER_URL=http://127.0.0.1:11334 RSPAMD_CONTROLLER_URL=http://127.0.0.1:11334
RSPAMD_CONTROLLER_PASSWORD=devpass RSPAMD_CONTROLLER_PASSWORD=devpass
# Spelling/grammar check in the compose window (LanguageTool). Empty = off.
LANGUAGETOOL_URL=http://127.0.0.1:8010
+1 -1
View File
@@ -1,5 +1,5 @@
up: ## start services up: ## start services
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool
down: ## stop services (data kept) down: ## stop services (data kept)
docker compose -f docker/docker-compose.yml down docker compose -f docker/docker-compose.yml down
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
+2
View File
@@ -23,6 +23,7 @@ def register_routers(app: FastAPI) -> None:
from app.api.saved_searches import router as saved_searches_router from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router from app.api.search import router as search_router
from app.api.settings import router as settings_router from app.api.settings import router as settings_router
from app.api.spellcheck import router as spellcheck_router
from app.api.threads import router as threads_router from app.api.threads import router as threads_router
from app.identity.routes import router as auth_router from app.identity.routes import router as auth_router
from app.ws import router as ws_router from app.ws import router as ws_router
@@ -42,6 +43,7 @@ def register_routers(app: FastAPI) -> None:
avatar_router, avatar_router,
plugins_router, plugins_router,
admin_router, admin_router,
spellcheck_router,
): ):
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api") app.include_router(ws_router, prefix="/api")
+18 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -24,6 +25,10 @@ _ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
_ALLOWED_INVITE_FROM = {"contacts", "anyone"} _ALLOWED_INVITE_FROM = {"contacts", "anyone"}
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
class ReminderPref(BaseModel): class ReminderPref(BaseModel):
method: str = "alert" # alert = in-app notification, email = email me method: str = "alert" # alert = in-app notification, email = email me
minutes: int = 0 # minutes before the event start (0 = at time of event) minutes: int = 0 # minutes before the event start (0 = at time of event)
@@ -42,6 +47,10 @@ class SettingsOut(BaseModel):
invitation_calendar: str = "" # calendar id emailed events land in (blank => default) invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
invitation_file: bool = False # also mark the invite message read + filed invitation_file: bool = False # also mark the invite message read + filed
allowed_image_senders: list[str] = [] allowed_image_senders: list[str] = []
# Spelling and grammar check in the compose window, and its language
# ("auto" detects it per message).
spellcheck_enabled: bool = True
spellcheck_language: str = "auto"
class SettingsPatch(BaseModel): class SettingsPatch(BaseModel):
@@ -56,6 +65,8 @@ class SettingsPatch(BaseModel):
invitation_from: str | None = None invitation_from: str | None = None
invitation_calendar: str | None = None invitation_calendar: str | None = None
invitation_file: bool | None = None invitation_file: bool | None = None
spellcheck_enabled: bool | None = None
spellcheck_language: str | None = None
class QuotaOut(BaseModel): class QuotaOut(BaseModel):
@@ -113,6 +124,8 @@ def _settings_out(user: User) -> SettingsOut:
invitation_calendar=str(s.get("invitation_calendar", "")), invitation_calendar=str(s.get("invitation_calendar", "")),
invitation_file=bool(s.get("invitation_file", False)), invitation_file=bool(s.get("invitation_file", False)),
allowed_image_senders=sorted(allowed_image_senders(user)), allowed_image_senders=sorted(allowed_image_senders(user)),
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
spellcheck_language=str(s.get("spellcheck_language", "auto")),
) )
@@ -144,9 +157,7 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
if body.default_calendar is not None: if body.default_calendar is not None:
updates["default_calendar"] = body.default_calendar updates["default_calendar"] = body.default_calendar
if body.default_reminders_timed is not None: if body.default_reminders_timed is not None:
updates["default_reminders_timed"] = [ updates["default_reminders_timed"] = [r.model_dump() for r in body.default_reminders_timed]
r.model_dump() for r in body.default_reminders_timed
]
if body.default_reminders_allday is not None: if body.default_reminders_allday is not None:
updates["default_reminders_allday"] = [ updates["default_reminders_allday"] = [
r.model_dump() for r in body.default_reminders_allday r.model_dump() for r in body.default_reminders_allday
@@ -159,6 +170,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["invitation_calendar"] = body.invitation_calendar updates["invitation_calendar"] = body.invitation_calendar
if body.invitation_file is not None: if body.invitation_file is not None:
updates["invitation_file"] = body.invitation_file updates["invitation_file"] = body.invitation_file
if body.spellcheck_enabled is not None:
updates["spellcheck_enabled"] = body.spellcheck_enabled
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
updates["spellcheck_language"] = body.spellcheck_language
user.settings = updates user.settings = updates
await db.commit() await db.commit()
return _settings_out(user) return _settings_out(user)
+187
View File
@@ -0,0 +1,187 @@
"""Spelling and grammar check for the compose window, backed by LanguageTool.
The browser sends the text it wants checked here, never to LanguageTool
itself: the engine stays on the internal network, only signed-in users reach
it, and nothing leaves the server. When no engine is configured (or it is
down), the check answers 503 and the compose window simply goes without.
"""
from __future__ import annotations
import asyncio
import time
from collections import Counter
from typing import Any
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.errors import raise_api_error
from app.config import get_settings
from app.identity.deps import CurrentUser, require_csrf
router = APIRouter(prefix="/spellcheck", tags=["compose"])
_TIMEOUT = 10.0
# A message body is checked as a whole; past this it is a document, not mail.
MAX_TEXT = 20_000
_MAX_SUGGESTIONS = 6
# With "auto", LanguageTool settles on ONE language per request, so a message
# mixing Polish and English paragraphs would have every English word flagged as
# a Polish typo. Auto-detected text is therefore checked in chunks, each in its
# own language. A chunk is at least this many characters of consecutive lines:
# a greeting alone ("Witam,") is too short to detect reliably, so it rides with
# the lines after it.
_MIN_CHUNK = 60
_PARALLEL = 4
class CheckIn(BaseModel):
text: str = Field(max_length=MAX_TEXT)
# "auto" or a LanguageTool code ("pl-PL", "en-GB", "de-DE" ...).
language: str = Field(default="auto", max_length=16)
class Issue(BaseModel):
offset: int
length: int
message: str
short_message: str
replacements: list[str]
# spelling | grammar | style: what the underline colour follows.
kind: str
rule_id: str
class CheckOut(BaseModel):
language: str # the language checked (detected, with "auto")
language_name: str
issues: list[Issue]
class LanguageOut(BaseModel):
code: str
name: str
def _base() -> str:
url = get_settings().languagetool_url.rstrip("/")
if not url:
raise_api_error(503, "spellcheck_unavailable", "spell checking is not set up")
return url
def _kind(issue_type: str) -> str:
if issue_type == "misspelling":
return "spelling"
if issue_type in ("style", "typographical", "whitespace", "register", "locale-violation"):
return "style"
return "grammar"
def chunks(text: str) -> list[tuple[int, str]]:
"""Split ``text`` into (offset, chunk) runs of whole lines, each at least
_MIN_CHUNK characters where the text allows; a blank line always ends one."""
out: list[tuple[int, str]] = []
start = pos = 0
for line in text.split("\n"):
end = pos + len(line)
if not line.strip():
if text[start:pos].strip():
# Up to the line break before the blank line, not including it.
out.append((start, text[start:pos].rstrip("\n")))
start = end + 1
elif end - start >= _MIN_CHUNK:
out.append((start, text[start:end]))
start = end + 1
pos = end + 1
if text[start:].strip():
out.append((start, text[start:]))
return out
async def _check_one(
client: httpx.AsyncClient, base: str, text: str, language: str
) -> dict[str, Any]:
resp = await client.post(f"{base}/v2/check", data={"text": text, "language": language})
resp.raise_for_status()
data: dict[str, Any] = resp.json()
return data
def _issues(data: dict[str, Any], shift: int) -> list[Issue]:
return [
Issue(
offset=int(m["offset"]) + shift,
length=int(m["length"]),
message=str(m.get("message", "")),
short_message=str(m.get("shortMessage", "")),
replacements=[str(r["value"]) for r in (m.get("replacements") or [])][
:_MAX_SUGGESTIONS
],
kind=_kind(str((m.get("rule") or {}).get("issueType", ""))),
rule_id=str((m.get("rule") or {}).get("id", "")),
)
for m in data.get("matches") or []
]
def _detected(data: dict[str, Any]) -> tuple[str, str]:
lang = data.get("language") or {}
detected = lang.get("detectedLanguage") or lang
return str(detected.get("code", "")), str(detected.get("name", ""))
@router.post("", response_model=CheckOut, dependencies=[Depends(require_csrf)])
async def check(body: CheckIn, user: CurrentUser) -> CheckOut:
base = _base()
if not body.text.strip():
return CheckOut(language=body.language, language_name="", issues=[])
parts = chunks(body.text) if body.language == "auto" else [(0, body.text)]
gate = asyncio.Semaphore(_PARALLEL)
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
async def one(text: str) -> dict[str, Any]:
async with gate:
return await _check_one(client, base, text, body.language)
results = await asyncio.gather(*(one(text) for _, text in parts))
except (httpx.HTTPError, ValueError):
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
issues = [
i for (shift, _), data in zip(parts, results, strict=True) for i in _issues(data, shift)
]
# The language reported is the one most of the text was written in.
weight: Counter[tuple[str, str]] = Counter()
for (_, text), data in zip(parts, results, strict=True):
weight[_detected(data)] += len(text)
(code, name), _ = weight.most_common(1)[0]
return CheckOut(language=code or body.language, language_name=name, issues=issues)
_languages_cache: tuple[float, list[LanguageOut]] | None = None
@router.get("/languages", response_model=list[LanguageOut])
async def languages(user: CurrentUser) -> list[LanguageOut]:
"""What the engine can check, for the language picker in Settings."""
global _languages_cache
if _languages_cache and time.monotonic() - _languages_cache[0] < 3600:
return _languages_cache[1]
base = _base()
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(f"{base}/v2/languages")
resp.raise_for_status()
rows: list[dict[str, Any]] = resp.json()
except (httpx.HTTPError, ValueError):
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
seen: dict[str, str] = {}
for row in rows:
seen.setdefault(str(row["longCode"]), str(row["name"]))
out = sorted((LanguageOut(code=c, name=n) for c, n in seen.items()), key=lambda x: x.name)
_languages_cache = (time.monotonic(), out)
return out
+3
View File
@@ -68,6 +68,9 @@ class Settings(BaseSettings):
# Server-side sender avatars (Gravatar + domain favicon). Fetched by the server # Server-side sender avatars (Gravatar + domain favicon). Fetched by the server
# (never the browser) so senders can't see the user's IP or read activity. # (never the browser) so senders can't see the user's IP or read activity.
sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED") sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED")
# Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# Auto-purge Trash/Spam messages older than this many days (0 disables). # Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS") trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
+182
View File
@@ -0,0 +1,182 @@
"""Spelling/grammar check proxy (compose window -> LanguageTool).
LanguageTool is replaced by a stub transport, so these run without the engine.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import spellcheck
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
LT_REPLY: dict[str, Any] = {
"language": {
"code": "auto",
"name": "Auto",
"detectedLanguage": {"code": "pl-PL", "name": "Polish"},
},
"matches": [
{
"offset": 12,
"length": 6,
"message": "Wyraz „prosze” to najczęściej literówka",
"shortMessage": "Literówka",
"replacements": [{"value": "proszę"}],
"rule": {"id": "PL_PROSZE", "issueType": "misspelling"},
},
{
"offset": 40,
"length": 11,
"message": "Powtórzony wyraz",
"shortMessage": "",
"replacements": [{"value": v} for v in "abcdefgh"],
"rule": {"id": "WORD_REPEAT", "issueType": "duplication"},
},
],
}
@pytest_asyncio.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"sp{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
async with httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
) as c:
yield c
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _stub(monkeypatch: pytest.MonkeyPatch, handler: Any) -> list[httpx.Request]:
"""Route the module's outbound calls to `handler`; return what was sent."""
seen: list[httpx.Request] = []
real = httpx.AsyncClient
def wrapped(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
monkeypatch.setattr(
spellcheck.httpx,
"AsyncClient",
lambda **kw: real(transport=httpx.MockTransport(wrapped), **kw),
)
monkeypatch.setattr(get_settings(), "languagetool_url", "http://lt.test:8010")
return seen
async def test_check_returns_trimmed_typed_issues(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
resp = await client.post(
"/api/spellcheck",
json={"text": "Dzień dobry prosze o pliki ważne ważne", "language": "auto"},
)
assert resp.status_code == 200
body = resp.json()
assert (body["language"], body["language_name"]) == ("pl-PL", "Polish")
first, second = body["issues"]
assert first["kind"] == "spelling" and first["replacements"] == ["proszę"]
assert second["kind"] == "grammar" and len(second["replacements"]) == 6
assert b"language=auto" in sent[0].content
async def test_unconfigured_or_down_engine_is_a_503(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def down(_r: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
_stub(monkeypatch, down)
resp = await client.post("/api/spellcheck", json={"text": "hello"})
assert resp.json()["error"]["code"] == "spellcheck_unavailable"
monkeypatch.setattr(get_settings(), "languagetool_url", "")
resp = await client.post("/api/spellcheck", json={"text": "hello"})
assert resp.status_code == 503
async def test_empty_text_never_reaches_the_engine(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
resp = await client.post("/api/spellcheck", json={"text": " \n "})
assert resp.json()["issues"] == [] and sent == []
async def test_settings_store_the_spellcheck_choice(client: httpx.AsyncClient) -> None:
out = (await client.patch("/api/settings", json={"spellcheck_language": "pl-PL"})).json()
assert out["spellcheck_language"] == "pl-PL" and out["spellcheck_enabled"] is True
out = (await client.patch("/api/settings", json={"spellcheck_enabled": False})).json()
assert out["spellcheck_enabled"] is False
bad = (await client.patch("/api/settings", json={"spellcheck_language": "<script>"})).json()
assert bad["spellcheck_language"] == "pl-PL" # rejected, previous value kept
def test_auto_text_is_split_into_language_sized_chunks() -> None:
text = "Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.\n\nHi\n\nThanks!"
parts = spellcheck.chunks(text)
# The greeting rides with the long line after it; blank lines end a chunk.
assert [t for _, t in parts] == [
"Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.",
"Hi",
"Thanks!",
]
# Offsets point back into the original text.
assert all(text[o : o + len(t)] == t for o, t in parts)
async def test_auto_checks_each_chunk_and_shifts_offsets(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def per_chunk(request: httpx.Request) -> httpx.Response:
body = request.content.decode()
lang = ("pl-PL", "Polish") if "Witam" in body else ("en-US", "English (US)")
return httpx.Response(
200,
json={
"language": {"detectedLanguage": {"code": lang[0], "name": lang[1]}},
"matches": [
{
"offset": 0,
"length": 2,
"message": "m",
"replacements": [],
"rule": {"id": "R", "issueType": "misspelling"},
}
],
},
)
sent = _stub(monkeypatch, per_chunk)
text = "Witam, to jest długi polski akapit pełen słów i zdań.\n\nHello there friend"
out = (await client.post("/api/spellcheck", json={"text": text})).json()
assert len(sent) == 2
assert [i["offset"] for i in out["issues"]] == [0, text.index("Hello")]
assert out["language_name"] == "Polish" # most of the text
+17
View File
@@ -118,6 +118,23 @@ services:
retries: 10 retries: 10
# Test mail servers (integration tests + manual dev against a throwaway mailbox) # Test mail servers (integration tests + manual dev against a throwaway mailbox)
# Spelling and grammar checking for the compose window (POST /api/spellcheck
# forwards to it; the browser never talks to it directly). No n-gram data:
# it would add several GB per language for a modest gain in grammar checks.
languagetool:
image: erikvl87/languagetool:6.8
environment:
Java_Xms: 256m
Java_Xmx: 1g
ports:
- "127.0.0.1:8010:8010"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8010/v2/languages || exit 1"]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
greenmail: greenmail:
image: greenmail/standalone:2.0.1 image: greenmail/standalone:2.0.1
environment: environment:
+67 -1
View File
@@ -159,6 +159,21 @@ export function Composer({
const [showToolbar, setShowToolbar] = useState(true); const [showToolbar, setShowToolbar] = useState(true);
const [fromOpen, setFromOpen] = useState(false); const [fromOpen, setFromOpen] = useState(false);
const [moreOpen, setMoreOpen] = 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 moreRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLDivElement>(null); const fromRef = useRef<HTMLDivElement>(null);
const [attachments, setAttachments] = useState<AttachmentOut[]>([]); const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
@@ -656,7 +671,7 @@ export function Composer({
{t("compose.more")} {t("compose.more")}
</HeaderButton> </HeaderButton>
{moreOpen && ( {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 <button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover" className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => { onClick={() => {
@@ -676,6 +691,56 @@ export function Composer({
> >
{t("compose.insertSignature")} {t("compose.insertSignature")}
</button> </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 <button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover" className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => { onClick={() => {
@@ -870,6 +935,7 @@ export function Composer({
setBodyHtml(html); setBodyHtml(html);
}} }}
showToolbar={showToolbar} showToolbar={showToolbar}
spellLanguage={spellLang}
// With a height to divide, the body takes what is left and // With a height to divide, the body takes what is left and
// scrolls. A floor here would instead push the window past that // scrolls. A floor here would instead push the window past that
// height — and `overflow-hidden` would clip the difference. // height — and `overflow-hidden` would clip the difference.
@@ -1,6 +1,26 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import {
canHighlight,
clearPaint,
collectText,
ignoreKey,
paint,
place,
stillValid,
type PlacedIssue,
} from "./spellcheck";
// Imperative handle for the composer to manage the signature block. // Imperative handle for the composer to manage the signature block.
export interface RichTextEditorHandle { export interface RichTextEditorHandle {
@@ -170,9 +190,12 @@ export const RichTextEditor = forwardRef<
onReady?: (el: HTMLDivElement) => void; onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean; showToolbar?: boolean;
minHeight?: string; // Tailwind min-h-* class for the editable area minHeight?: string; // Tailwind min-h-* class for the editable area
/** This message's spell-check language from the compose window's More menu:
* "off", "auto" or a language code. Unset follows Settings. */
spellLanguage?: string | null;
} }
>(function RichTextEditor( >(function RichTextEditor(
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" }, { initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]", spellLanguage: spellOverride },
handleRef, handleRef,
) { ) {
const t = useT(); const t = useT();
@@ -193,6 +216,27 @@ export const RichTextEditor = forwardRef<
const [newLinkRange, setNewLinkRange] = useState<Range | null>(null); const [newLinkRange, setNewLinkRange] = useState<Range | null>(null);
const linkPopupRef = useRef<HTMLDivElement>(null); const linkPopupRef = useRef<HTMLDivElement>(null);
// Spelling and grammar check (LanguageTool via /api/spellcheck), when the user
// has it on and the server has an engine. Off -> the browser's own checker.
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const [spellUnavailable, setSpellUnavailable] = useState(false);
const spellWanted = spellOverride
? spellOverride !== "off"
: Boolean(settings.data?.spellcheck_enabled);
const spellOn = spellWanted && !spellUnavailable && canHighlight();
const spellLanguage =
spellOverride && spellOverride !== "off"
? spellOverride
: (settings.data?.spellcheck_language ?? "auto");
const spellOwner = useRef({}); // this editor's key in the shared highlight set
const issuesRef = useRef<PlacedIssue[]>([]);
const ignoredRef = useRef(new Set<string>());
const spellSeq = useRef(0);
const spellTimer = useRef<number | null>(null);
const [spellIssue, setSpellIssue] = useState<PlacedIssue | null>(null);
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null);
const spellPopupRef = useRef<HTMLDivElement>(null);
// A reply's quoted original starts folded away behind the "•••" pill. // A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false);
@@ -306,8 +350,63 @@ export const RichTextEditor = forwardRef<
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [colorMenu]); }, [colorMenu]);
const runSpellcheck = useCallback(async () => {
const ed = ref.current;
if (!ed || !spellOn) return;
const map = collectText(ed);
const seq = ++spellSeq.current;
if (!map.text.trim() || map.text.length > 20_000) {
issuesRef.current = [];
paint(spellOwner.current, []);
return;
}
try {
const res = await api.spellcheck(map.text, spellLanguage);
if (seq !== spellSeq.current) return; // a newer check is on its way
issuesRef.current = place(map, res.issues).filter(
(i) => !ignoredRef.current.has(ignoreKey(i)),
);
paint(spellOwner.current, issuesRef.current);
} catch (e) {
// No engine on this server (or it is down): fall back to the browser's.
if (e instanceof ApiError && e.status === 503) setSpellUnavailable(true);
}
}, [spellOn, spellLanguage]);
// Check again once typing pauses. Marks on words just edited go at once.
const scheduleSpellcheck = useCallback(() => {
if (!spellOn) return;
issuesRef.current = issuesRef.current.filter(stillValid);
paint(spellOwner.current, issuesRef.current);
if (spellTimer.current) window.clearTimeout(spellTimer.current);
spellTimer.current = window.setTimeout(() => void runSpellcheck(), 800);
}, [spellOn, runSpellcheck]);
// On open, when turned on, or when the language changes; off clears the marks.
useEffect(() => {
if (spellOn) scheduleSpellcheck();
else {
// A check still on its way must not paint marks once checking is off.
spellSeq.current++;
if (spellTimer.current) window.clearTimeout(spellTimer.current);
issuesRef.current = [];
clearPaint(spellOwner.current);
}
}, [spellOn, scheduleSpellcheck]);
useEffect(() => {
const owner = spellOwner.current;
const seq = spellSeq; // the counter itself, not a value read at cleanup
return () => {
seq.current++; // nor after the editor is gone
if (spellTimer.current) window.clearTimeout(spellTimer.current);
clearPaint(owner);
};
}, []);
const emit = () => { const emit = () => {
if (ref.current) onInput(contentHtml(ref.current)); if (ref.current) onInput(contentHtml(ref.current));
scheduleSpellcheck();
}; };
const exec = (command: string, value?: string) => { const exec = (command: string, value?: string) => {
@@ -441,6 +540,88 @@ export const RichTextEditor = forwardRef<
const a = el.closest("a"); const a = el.closest("a");
setSelLink(a && ref.current?.contains(a) ? a : null); setSelLink(a && ref.current?.contains(a) ? a : null);
setLinkEdit(null); setLinkEdit(null);
setSpellIssue(null);
if (a || !spellOn) return;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.anchorNode) return;
const node = sel.anchorNode;
const offset = sel.anchorOffset;
const hit = issuesRef.current.find((i) => {
if (!stillValid(i)) return false;
try {
return i.range.comparePoint(node, offset) === 0;
} catch {
return false;
}
});
if (hit) setSpellIssue(hit);
};
// The spell popup sits just under the marked words, like the link popup.
const computeSpellPos = useCallback(() => {
const ed = ref.current;
if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
const r = spellIssue.range.getBoundingClientRect();
const er = ed.getBoundingClientRect();
return {
left: Math.max(8, Math.min(r.left - er.left, er.width - 300)),
top: popupTop(r, spellPopupRef.current?.offsetHeight || 160),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spellIssue]);
useEffect(() => setSpellPos(computeSpellPos()), [spellIssue, computeSpellPos]);
useEffect(() => {
if (!spellIssue) return;
const onWin = () => setSpellPos(computeSpellPos());
const onDown = (e: MouseEvent) => {
if (spellPopupRef.current?.contains(e.target as Node)) return;
setSpellIssue(null);
};
window.addEventListener("resize", onWin);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("resize", onWin);
document.removeEventListener("mousedown", onDown);
};
}, [spellIssue, computeSpellPos]);
// Take a suggestion: replace the marked words as if typed (so Ctrl+Z undoes it).
const applySuggestion = (issue: PlacedIssue, replacement: string) => {
const ed = ref.current;
setSpellIssue(null);
if (!ed || !stillValid(issue)) return;
ed.focus();
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(issue.range);
document.execCommand("insertText", false, replacement);
issuesRef.current = issuesRef.current.filter((i) => i !== issue);
emit();
};
// "Ignore": this word (or this grammar point) is fine, for the rest of the message.
const ignoreIssue = (issue: PlacedIssue) => {
const key = ignoreKey(issue);
ignoredRef.current.add(key);
issuesRef.current = issuesRef.current.filter((i) => ignoreKey(i) !== key);
paint(spellOwner.current, issuesRef.current);
setSpellIssue(null);
};
// A popup's top edge, relative to the editor: just under its anchor, or just
// above it when there is no room left below — inside the editor's visible
// box and the window alike — so it is never cut off at the bottom.
const popupTop = (anchor: DOMRect, popupH: number) => {
const er = ref.current!.getBoundingClientRect();
const bottom = Math.min(er.bottom, window.innerHeight) - 4;
const top = Math.max(er.top, 0) + 4;
const below = anchor.bottom + 4;
const above = anchor.top - 4 - popupH;
const fitsBelow = below + popupH <= bottom;
return (fitsBelow || above < top ? below : above) - er.top;
}; };
// Where the link popup goes: just under the link (or the text becoming one), // Where the link popup goes: just under the link (or the text becoming one),
@@ -458,11 +639,27 @@ export const RichTextEditor = forwardRef<
const er = ed.getBoundingClientRect(); const er = ed.getBoundingClientRect();
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null; if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320)); const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320));
return { left, top: ar.bottom - er.top + 4 }; return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selLink, newLinkRange]); }, [selLink, newLinkRange]);
useEffect(() => setLinkPos(computeLinkPos()), [selLink, newLinkRange, computeLinkPos]); useEffect(() => setLinkPos(computeLinkPos()), [selLink, newLinkRange, computeLinkPos]);
// The first placement guesses a popup's height; once it is on screen, place it
// again with the real one (it may need to go above after all). Only a change
// in position re-renders, so this settles at once.
useLayoutEffect(() => {
if (spellIssue && spellPopupRef.current) {
const p = computeSpellPos();
if (p && (p.top !== spellPos?.top || p.left !== spellPos?.left)) setSpellPos(p);
}
if ((selLink || newLinkRange) && linkPopupRef.current) {
const p = computeLinkPos();
if (p && (p.top !== linkPos?.top || p.left !== linkPos?.left)) setLinkPos(p);
}
// linkEdit: switching the link popup to its URL field changes its height.
}, [spellIssue, spellPos, computeSpellPos, selLink, newLinkRange, linkEdit, linkPos, computeLinkPos]);
// While the URL field has focus the browser hides the text selection, so the // While the URL field has focus the browser hides the text selection, so the
// words about to become a link would look unselected. Keep them marked with a // words about to become a link would look unselected. Keep them marked with a
// CSS highlight (styled in index.css; nothing is written into the message). // CSS highlight (styled in index.css; nothing is written into the message).
@@ -556,6 +753,13 @@ export const RichTextEditor = forwardRef<
const onEditorKeyDown = (e: React.KeyboardEvent) => { const onEditorKeyDown = (e: React.KeyboardEvent) => {
// Typing (or Escape) in the message puts the link popup away. Escape stops // Typing (or Escape) in the message puts the link popup away. Escape stops
// here: the mail view's own Escape would otherwise minimise the whole window. // here: the mail view's own Escape would otherwise minimise the whole window.
if (spellIssue) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation(); // the popup, not the compose window
}
setSpellIssue(null);
}
if (selLink || newLinkRange) { if (selLink || newLinkRange) {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -737,7 +941,12 @@ export const RichTextEditor = forwardRef<
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
data-testid="rich-editor" data-testid="rich-editor"
onInput={(e) => onInput(contentHtml(e.currentTarget))} onInput={(e) => {
onInput(contentHtml(e.currentTarget));
scheduleSpellcheck();
}}
// Our own checker replaces the browser's, rather than doubling it up.
spellCheck={!spellOn}
onKeyUp={refreshActive} onKeyUp={refreshActive}
onMouseUp={refreshActive} onMouseUp={refreshActive}
onClick={onEditorClick} onClick={onEditorClick}
@@ -745,9 +954,51 @@ export const RichTextEditor = forwardRef<
onScroll={() => { onScroll={() => {
setBox(computeBox()); setBox(computeBox());
setLinkPos(computeLinkPos()); setLinkPos(computeLinkPos());
setSpellPos(computeSpellPos());
}} }}
className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`} className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/> />
{spellIssue && spellPos && (
<div
ref={spellPopupRef}
role="dialog"
aria-label={t("compose.spellTitle")}
className="absolute z-20 w-[300px] max-w-[calc(100%-16px)] rounded-lg border border-app-border bg-app-elevated p-3 text-[13px] text-app-fg shadow-xl"
style={{ left: spellPos.left, top: spellPos.top }}
>
<p className="mb-2 flex items-start gap-2">
<span
aria-hidden
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${
spellIssue.kind === "spelling" ? "bg-[#e5484d]" : "bg-app-accent"
}`}
/>
<span>{spellIssue.message}</span>
</p>
{spellIssue.replacements.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{spellIssue.replacements.slice(0, 5).map((r) => (
<button
key={r}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySuggestion(spellIssue, r)}
className="rounded-md bg-app-accent/10 px-2 py-0.5 font-medium text-app-accent hover:bg-app-accent/20"
>
{r || t("compose.spellRemove")}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => ignoreIssue(spellIssue)}
className="text-app-muted hover:text-app-fg hover:underline"
>
{t("compose.spellIgnore")}
</button>
</div>
)}
{(selLink || newLinkRange) && linkPos && ( {(selLink || newLinkRange) && linkPos && (
<div <div
ref={linkPopupRef} ref={linkPopupRef}
@@ -0,0 +1,143 @@
// Spelling and grammar marks for the compose editor (LanguageTool, through
// POST /api/spellcheck).
//
// Nothing is ever written into the message for this: the underlines are CSS
// highlights (`::highlight(wmc-spell)` / `wmc-grammar` in index.css) painted
// over live DOM Ranges, so the HTML that gets sent is exactly what was typed.
// Ranges also follow later edits on their own; an issue whose words changed is
// dropped rather than shown in the wrong place.
import type { SpellIssue } from "../../lib/types";
/** Where the checked text came from: each text node and its start offset in it. */
export interface TextMap {
text: string;
pieces: { node: Text; start: number }[];
}
export interface PlacedIssue extends SpellIssue {
range: Range;
/** The words as they were when checked: a mismatch means they were edited. */
original: string;
}
// The signature and the quoted original are not the user's writing to check.
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle";
const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/;
/**
* The editor's own text as one string, with a line break wherever the editor
* starts a new line (so "end." and "Next" on two lines never read as one word),
* plus the map back to the text nodes.
*/
export function collectText(root: HTMLElement): TextMap {
const pieces: TextMap["pieces"] = [];
let text = "";
const newline = () => {
if (text && !text.endsWith("\n")) text += "\n";
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const t = node as Text;
if (t.data) {
pieces.push({ node: t, start: text.length });
text += t.data;
}
return;
}
if (!(node instanceof HTMLElement)) return;
if (node.matches(SKIP)) return;
if (node.tagName === "BR") {
text += "\n";
return;
}
const block = BLOCK.test(node.tagName);
if (block) newline();
node.childNodes.forEach(walk);
if (block) newline();
};
root.childNodes.forEach(walk);
return { text, pieces };
}
/** The DOM point for a character offset into the collected text. */
function pointAt(map: TextMap, offset: number, isEnd: boolean): [Text, number] | null {
for (const { node, start } of map.pieces) {
const end = start + node.data.length;
if (isEnd ? offset > start && offset <= end : offset >= start && offset < end) {
return [node, offset - start];
}
}
return null;
}
/** Issues pinned to live Ranges in the editor; ones that no longer fit are left out. */
export function place(map: TextMap, issues: SpellIssue[]): PlacedIssue[] {
const out: PlacedIssue[] = [];
for (const issue of issues) {
const original = map.text.slice(issue.offset, issue.offset + issue.length);
// Spanning a line break means spanning elements: not something to underline.
if (!original || original.includes("\n")) continue;
const a = pointAt(map, issue.offset, false);
const b = pointAt(map, issue.offset + issue.length, true);
if (!a || !b || !a[0].isConnected || !b[0].isConnected) continue;
const range = document.createRange();
try {
range.setStart(a[0], a[1]);
range.setEnd(b[0], b[1]);
} catch {
continue; // the text changed under us
}
if (range.toString() !== original) continue;
out.push({ ...issue, range, original });
}
return out;
}
/** Still pointing at the words it was found on (not edited since). */
export function stillValid(issue: PlacedIssue): boolean {
return issue.range.startContainer.isConnected && issue.range.toString() === issue.original;
}
/** The key "Ignore" remembers an issue by, for the rest of this message. */
export function ignoreKey(issue: { rule_id: string; original: string }): string {
return `${issue.rule_id}\u0000${issue.original}`;
}
type HighlightRegistry = Map<string, unknown>;
function registry(): HighlightRegistry | null {
return (globalThis.CSS as unknown as { highlights?: HighlightRegistry }).highlights ?? null;
}
/** Whether this browser can draw the underlines at all. */
export function canHighlight(): boolean {
return registry() !== null && "Highlight" in globalThis;
}
// Several compose windows can be open at once, but a highlight name is global
// to the page. Each editor keeps its own marks here, and every repaint draws
// all of them, so one window's check never wipes another's underlines.
const owners = new Map<object, PlacedIssue[]>();
function repaint(): void {
const reg = registry();
const HighlightCtor = (globalThis as unknown as { Highlight?: new (...r: Range[]) => unknown })
.Highlight;
if (!reg || !HighlightCtor) return;
const all = [...owners.values()].flat();
reg.set("wmc-spell", new HighlightCtor(...all.filter((i) => i.kind === "spelling").map((i) => i.range)));
reg.set("wmc-grammar", new HighlightCtor(...all.filter((i) => i.kind !== "spelling").map((i) => i.range)));
}
/** Paint one editor's underlines: red for spelling, blue for grammar and style. */
export function paint(owner: object, issues: PlacedIssue[]): void {
owners.set(owner, issues);
repaint();
}
/** Remove one editor's underlines (it closed, or checking was turned off). */
export function clearPaint(owner: object): void {
owners.delete(owner);
repaint();
}
+13
View File
@@ -255,6 +255,13 @@ export const en = {
"compose.linkEdit": "Edit", "compose.linkEdit": "Edit",
"compose.linkDelete": "Delete", "compose.linkDelete": "Delete",
"compose.linkSave": "Save", "compose.linkSave": "Save",
"compose.spellTitle": "Spelling and grammar",
"compose.spellIgnore": "Ignore",
"compose.spellRemove": "(remove)",
"compose.spellMenu": "Spelling",
"compose.spellAuto": "Auto-detect",
"compose.spellOff": "Off",
"compose.spellOffMessage": "Off for this message",
"compose.imageTooLarge": "Image is too large (max 5 MB).", "compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.", "compose.attachTooLarge": "File exceeds the 25 MB limit.",
"compose.attachments": "Attachments", "compose.attachments": "Attachments",
@@ -352,6 +359,12 @@ export const en = {
"settings.appearance": "Appearance", "settings.appearance": "Appearance",
"settings.handle": "Handle", "settings.handle": "Handle",
"settings.recoveryEmail": "Recovery email", "settings.recoveryEmail": "Recovery email",
"settings.spellcheck": "Spelling and grammar check",
"settings.on": "On",
"settings.off": "Off",
"settings.spellcheckLanguage": "Language",
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
"settings.recoveryEmailHint": "settings.recoveryEmailHint":
"If you forget your password, a link to choose a new one is sent here.", "If you forget your password, a link to choose a new one is sent here.",
"settings.recoveryEmailNew": "Recovery email address", "settings.recoveryEmailNew": "Recovery email address",
+14
View File
@@ -192,6 +192,20 @@ body {
background-color: rgb(var(--app-accent) / 0.25); background-color: rgb(var(--app-accent) / 0.25);
} }
/* Spelling (red) and grammar/style (blue) marks from the compose editor's
LanguageTool check, drawn as CSS highlights over the text — never written
into the message (components/compose/spellcheck.ts). */
::highlight(wmc-spell) {
text-decoration: underline wavy #e5484d;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
::highlight(wmc-grammar) {
text-decoration: underline wavy rgb(var(--app-accent));
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* Thin, unobtrusive scrollbars that match the theme. */ /* Thin, unobtrusive scrollbars that match the theme. */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
+9
View File
@@ -23,6 +23,7 @@ import type {
SendResponse, SendResponse,
ServerConfig, ServerConfig,
SessionInfo, SessionInfo,
SpellCheckOut,
SyncWindow, SyncWindow,
ThreadDetail, ThreadDetail,
ThreadListOut, ThreadListOut,
@@ -230,11 +231,19 @@ export const api = {
| "invitation_from" | "invitation_from"
| "invitation_calendar" | "invitation_calendar"
| "invitation_file" | "invitation_file"
| "spellcheck_enabled"
| "spellcheck_language"
> >
>, >,
) => ) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }), apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"), quota: () => apiFetch<QuotaOut>("/settings/quota"),
spellcheck: (text: string, language: string) =>
apiFetch<SpellCheckOut>("/spellcheck", {
method: "POST",
body: JSON.stringify({ text, language }),
}),
spellcheckLanguages: () => apiFetch<{ code: string; name: string }[]>("/spellcheck/languages"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"), folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
// --- calendars (builtin calendar plugin) --- // --- calendars (builtin calendar plugin) ---
+21
View File
@@ -230,6 +230,27 @@ export interface UserSettings {
invitation_calendar: string; invitation_calendar: string;
invitation_file: boolean; invitation_file: boolean;
allowed_image_senders: string[]; allowed_image_senders: string[];
/** Spelling and grammar check in the compose window (LanguageTool). */
spellcheck_enabled: boolean;
/** "auto", or a LanguageTool code such as "pl-PL". */
spellcheck_language: string;
}
/** One problem the spell checker found, by character offset in the checked text. */
export interface SpellIssue {
offset: number;
length: number;
message: string;
short_message: string;
replacements: string[];
kind: "spelling" | "grammar" | "style";
rule_id: string;
}
export interface SpellCheckOut {
language: string;
language_name: string;
issues: SpellIssue[];
} }
export interface QuotaOut { export interface QuotaOut {
+65
View File
@@ -720,6 +720,70 @@ function ServerRow({
); );
} }
// Spelling and grammar check in the compose window (LanguageTool on the server).
function SpellcheckSetting() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
// Also tells us whether this server has a checker at all (503 when not).
const languages = useQuery({
queryKey: ["spellcheck-languages"],
queryFn: api.spellcheckLanguages,
retry: false,
staleTime: 3_600_000,
});
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
});
const on = settings.data?.spellcheck_enabled ?? true;
const unavailable = languages.isError;
return (
<div className="space-y-2 border-b border-app-border py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.spellcheck")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={unavailable || patch.isPending}
onClick={() => patch.mutate({ spellcheck_enabled: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
on === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "settings.on" : "settings.off")}
</button>
))}
</div>
</div>
{unavailable ? (
<p className="text-xs text-app-faint">{t("settings.spellcheckUnavailable")}</p>
) : (
<>
<label className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.spellcheckLanguage")}</span>
<select
className="max-w-[60%] rounded border border-app-border bg-app-surface px-2 py-1 disabled:opacity-50"
disabled={!on || patch.isPending}
value={settings.data?.spellcheck_language ?? "auto"}
onChange={(e) => patch.mutate({ spellcheck_language: e.target.value })}
>
<option value="auto">{t("settings.spellcheckAuto")}</option>
{(languages.data ?? []).map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
</label>
</>
)}
</div>
);
}
function AppearanceSection() { function AppearanceSection() {
const t = useT(); const t = useT();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -757,6 +821,7 @@ function AppearanceSection() {
{/* Saved per device, like the theme: the right choice depends on the screen. */} {/* Saved per device, like the theme: the right choice depends on the screen. */}
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p> <p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
</div> </div>
<SpellcheckSetting />
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm"> <label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.threadOrder")}</span> <span className="text-app-muted">{t("settings.threadOrder")}</span>
<select <select