Compose is plaintext today; the reader already renders HTML (ui/reader/HtmlBody.kt). This
ticket brings rich composition to the editor. Sending is #37; signatures are #38.
Decision: custom Compose-native model, no third-party dependency
Considered three approaches for the editor/model itself:
Custom Compose-native model — our own span/range data model
(text + style/link/alignment/image ranges) rendered through BasicTextField/AnnotatedString,
with a hand-written HTML serializer/parser. Zero third-party runtime dependency.
A third-party Compose-native rich-text library (e.g. richeditor-compose, MIT) — same
architecture as (1) but maintained upstream.
WebView + contenteditable + a bundled open-source JS editor (Quill/TipTap-style) — the
common cross-platform approach, distinct from the reader's existing WebView use (which is
read-only, sandboxed HTML rendering, already audited under #15's checklist — editing inside a
WebView is a materially different, harder-to-secure-and-test surface).
Decision: (1). Reasoning against the compliance constraints this app has to satisfy
(F-Droid, Play Store, GPL-3.0):
GPL-3.0: a fully custom, in-repo implementation needs no license reconciliation at all — it's
already GPL-3.0-or-later like every other file here. (2) would also be fine license-wise
(MIT is one-directionally GPL-compatible, same relationship already relied on for Apache-2.0
AndroidX deps in docs/fdroid-compliance.md's license table), but (3) requires auditing whatever
JS editor is bundled and staying strictly on its free-as-in-freedom core (avoiding "open-core"
editors with a separate proprietary tier).
F-Droid: (1) adds zero new dependency and zero anti-feature surface — nothing to add to docs/fdroid-compliance.md's audit. (3) is only viable if the JS/CSS is bundled in assets/
and loaded via file:///android_asset/, never fetched from a CDN at runtime (F-Droid's
anti-feature policy is specifically about apps that download executable code over the network).
Play Store: no meaningful difference between the three options here.
Accessibility: this ticket's own acceptance criteria requires TalkBack + large-font support.
A native Compose text field ((1) or (2)) inherits that for free; WebView-hosted contenteditable ((3)) is the weakest of the three on this axis.
Went with (1) over (2) mainly because the exact model needed (paragraph alignment, inline cid:
images, a message-wide base font, per-run parameterized styles) is specific to how this app's
outbox/reply-quoting/draft-persistence code already works (see #37's SmtpSender/GraphSender, data/ReplyBuilder.kt) — building it in-repo means the model and the HTML shape it produces are
exactly what those consumers need, with no impedance mismatch to a library's own opinions. Revisit (2) only if the hand-rolled parser/editor starts costing more maintenance time than
adopting a library would — not preemptively.
This is already substantially implemented on main (see Scope below) — this section documents why, so the decision doesn't get silently re-litigated later.
Scope
Rich-text editor in ComposeScreen with a formatting toolbar — done: ui/compose/RichTextEditor.kt's RichTextBodyField/FormattingToolbar, wired into ComposeScreen.kt (~line 183). Bold/italic/underline, bulleted + numbered lists, block quote,
and link are all present (format_bold … format_link_* strings). Headings were only
"consider" in the original scope and are not implemented — see the gap below for the
decision this ticket still needs.
Internal rich model that serializes to HTML for the message body — done: richtext/RichText.kt (RichTextContent, every RichStyle variant, RichAlignment, RichImage, RichBaseStyle, RichLink) + richtext/RichTextHtml.kt / RichTextHtmlParser.kt (hand-written round-trip, no parsing library). Model-layer tests exist: RichTextEditingTest.kt, RichTextHtmlTest.kt.
Preserve a smooth plaintext-only experience; keep it accessible (TalkBack, large fonts) —
implemented (RichTextContent.hasFormatting() gates HTML emission so an unformatted message
stays plaintext-only; toolbar buttons carry contentDescription/onClickLabel) but not yet
verified by any test — see the gap below.
Round the compose input/container corners — done via #23 (closed): every OutlinedTextField
in ComposeScreen.kt and RichTextBodyField already uses a rounded shape.
Remaining gaps
Decide on headings. Not implemented anywhere in the model or toolbar. Either add an
H1/H2-style block marker (mirroring the existing bullet/numbered/quote BlockMarker pattern
in richtext/RichTextEditing.kt) or explicitly drop it from scope so it stops being an
ambiguous "consider" item. Low priority either way — call it out so it's a decision, not an
oversight.
UI-layer test coverage for RichTextEditor.kt. Everything tested so far
(RichTextEditingTest.kt, RichTextHtmlTest.kt) exercises the underlying model
(RichTextEditing, RichTextHtml) — nothing tests the Compose-editor glue in RichTextEditor.kt itself: applyStyle/applyBlock/applyLink, AnnotatedString.toRichContent()/RichTextContent.toAnnotatedString(), or FormattingToolbar's active/inactive toggle state. These internal functions operate on TextFieldValue/AnnotatedString, which are plain Kotlin types usable directly in a JVM unit
test — add a RichTextEditorTest.kt alongside the existing two. Separately, add an androidTest
(ComposeScreenTest.kt) that taps a toolbar button and asserts the resulting content — this is
the only way to actually verify the KDoc's accessibility claim (content descriptions, toggle
state) rather than assume it.
Acceptance criteria
The user can apply formatting and it round-trips into the sent message — the #37 half of this
is confirmed: SmtpSenderTest.kt (GreenMail-backed) verifies a formatted message sends as multipart/alternative (nested inside multipart/mixed when there's also an attachment), and ComposeViewModelTest.kt confirms bodyHtml stays null when nothing is formatted and carries
the right HTML when it is. So this ticket is not blocked on #37 for closing.
Plaintext-only composing is unchanged in feel — behaviorally true by inspection
(hasFormatting() gate) but not yet covered by a test at the editor layer; folded into the gap
above.
Pairs with #37 (send path — confirmed implemented/tested, see above); folds in #23 (done).
Part of #14.
## Context
Compose is plaintext today; the reader already renders HTML (`ui/reader/HtmlBody.kt`). This
ticket brings rich composition to the editor. Sending is #37; signatures are #38.
## Decision: custom Compose-native model, no third-party dependency
Considered three approaches for the editor/model itself:
1. **Custom Compose-native model** — our own span/range data model
(`text` + style/link/alignment/image ranges) rendered through `BasicTextField`/`AnnotatedString`,
with a hand-written HTML serializer/parser. Zero third-party runtime dependency.
2. **A third-party Compose-native rich-text library** (e.g. `richeditor-compose`, MIT) — same
architecture as (1) but maintained upstream.
3. **WebView + `contenteditable` + a bundled open-source JS editor** (Quill/TipTap-style) — the
common cross-platform approach, distinct from the reader's existing WebView use (which is
read-only, sandboxed HTML *rendering*, already audited under #15's checklist — editing inside a
WebView is a materially different, harder-to-secure-and-test surface).
**Decision: (1).** Reasoning against the compliance constraints this app has to satisfy
(F-Droid, Play Store, GPL-3.0):
- **GPL-3.0**: a fully custom, in-repo implementation needs no license reconciliation at all — it's
already `GPL-3.0-or-later` like every other file here. (2) would also be fine license-wise
(MIT is one-directionally GPL-compatible, same relationship already relied on for Apache-2.0
AndroidX deps in `docs/fdroid-compliance.md`'s license table), but (3) requires auditing whatever
JS editor is bundled and staying strictly on its free-as-in-freedom core (avoiding "open-core"
editors with a separate proprietary tier).
- **F-Droid**: (1) adds zero new dependency and zero anti-feature surface — nothing to add to
`docs/fdroid-compliance.md`'s audit. (3) is only viable if the JS/CSS is bundled in `assets/`
and loaded via `file:///android_asset/`, never fetched from a CDN at runtime (F-Droid's
anti-feature policy is specifically about apps that download executable code over the network).
- **Play Store**: no meaningful difference between the three options here.
- **Accessibility**: this ticket's own acceptance criteria requires TalkBack + large-font support.
A native Compose text field ((1) or (2)) inherits that for free; WebView-hosted
`contenteditable` ((3)) is the weakest of the three on this axis.
Went with (1) over (2) mainly because the exact model needed (paragraph alignment, inline `cid:`
images, a message-wide base font, per-run parameterized styles) is specific to how this app's
outbox/reply-quoting/draft-persistence code already works (see #37's `SmtpSender`/`GraphSender`,
`data/ReplyBuilder.kt`) — building it in-repo means the model and the HTML shape it produces are
exactly what those consumers need, with no impedance mismatch to a library's own opinions.
**Revisit (2) only if the hand-rolled parser/editor starts costing more maintenance time than
adopting a library would — not preemptively.**
This is already substantially implemented on `main` (see Scope below) — this section documents
*why*, so the decision doesn't get silently re-litigated later.
## Scope
- [x] Rich-text editor in `ComposeScreen` with a formatting toolbar — done:
`ui/compose/RichTextEditor.kt`'s `RichTextBodyField`/`FormattingToolbar`, wired into
`ComposeScreen.kt` (~line 183). Bold/italic/underline, bulleted + numbered lists, block quote,
and link are all present (`format_bold` … `format_link_*` strings). **Headings were only
"consider" in the original scope and are not implemented** — see the gap below for the
decision this ticket still needs.
- [x] Internal rich model that serializes to HTML for the message body — done:
`richtext/RichText.kt` (`RichTextContent`, every `RichStyle` variant, `RichAlignment`,
`RichImage`, `RichBaseStyle`, `RichLink`) + `richtext/RichTextHtml.kt` /
`RichTextHtmlParser.kt` (hand-written round-trip, no parsing library). Model-layer tests exist:
`RichTextEditingTest.kt`, `RichTextHtmlTest.kt`.
- [x] Preserve a smooth plaintext-only experience; keep it accessible (TalkBack, large fonts) —
implemented (`RichTextContent.hasFormatting()` gates HTML emission so an unformatted message
stays plaintext-only; toolbar buttons carry `contentDescription`/`onClickLabel`) but **not yet
verified by any test** — see the gap below.
- [x] Round the compose input/container corners — done via #23 (closed): every `OutlinedTextField`
in `ComposeScreen.kt` and `RichTextBodyField` already uses a rounded `shape`.
## Remaining gaps
- [ ] **Decide on headings.** Not implemented anywhere in the model or toolbar. Either add an
H1/H2-style block marker (mirroring the existing bullet/numbered/quote `BlockMarker` pattern
in `richtext/RichTextEditing.kt`) or explicitly drop it from scope so it stops being an
ambiguous "consider" item. Low priority either way — call it out so it's a decision, not an
oversight.
- [ ] **UI-layer test coverage for `RichTextEditor.kt`.** Everything tested so far
(`RichTextEditingTest.kt`, `RichTextHtmlTest.kt`) exercises the underlying model
(`RichTextEditing`, `RichTextHtml`) — nothing tests the Compose-editor glue in
`RichTextEditor.kt` itself: `applyStyle`/`applyBlock`/`applyLink`,
`AnnotatedString.toRichContent()`/`RichTextContent.toAnnotatedString()`, or
`FormattingToolbar`'s active/inactive toggle state. These `internal` functions operate on
`TextFieldValue`/`AnnotatedString`, which are plain Kotlin types usable directly in a JVM unit
test — add a `RichTextEditorTest.kt` alongside the existing two. Separately, add an androidTest
(`ComposeScreenTest.kt`) that taps a toolbar button and asserts the resulting content — this is
the only way to actually verify the KDoc's accessibility claim (content descriptions, toggle
state) rather than assume it.
## Acceptance criteria
- The user can apply formatting and it round-trips into the sent message — **the #37 half of this
is confirmed**: `SmtpSenderTest.kt` (GreenMail-backed) verifies a formatted message sends as
`multipart/alternative` (nested inside `multipart/mixed` when there's also an attachment), and
`ComposeViewModelTest.kt` confirms `bodyHtml` stays `null` when nothing is formatted and carries
the right HTML when it is. So this ticket is not blocked on #37 for closing.
- Plaintext-only composing is unchanged in feel — behaviorally true by inspection
(`hasFormatting()` gate) but not yet covered by a test at the editor layer; folded into the gap
above.
## Relevant files
- `ui/compose/ComposeScreen.kt`, `ui/compose/ComposeViewModel.kt`, `ui/compose/RichTextEditor.kt`,
`richtext/RichText.kt`, `richtext/RichTextEditing.kt`, `richtext/RichTextHtml.kt`,
`richtext/RichTextHtmlParser.kt`.
## Notes
Pairs with #37 (send path — confirmed implemented/tested, see above); folds in #23 (done).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Part of #14.
Context
Compose is plaintext today; the reader already renders HTML (
ui/reader/HtmlBody.kt). Thisticket brings rich composition to the editor. Sending is #37; signatures are #38.
Decision: custom Compose-native model, no third-party dependency
Considered three approaches for the editor/model itself:
(
text+ style/link/alignment/image ranges) rendered throughBasicTextField/AnnotatedString,with a hand-written HTML serializer/parser. Zero third-party runtime dependency.
richeditor-compose, MIT) — samearchitecture as (1) but maintained upstream.
contenteditable+ a bundled open-source JS editor (Quill/TipTap-style) — thecommon cross-platform approach, distinct from the reader's existing WebView use (which is
read-only, sandboxed HTML rendering, already audited under #15's checklist — editing inside a
WebView is a materially different, harder-to-secure-and-test surface).
Decision: (1). Reasoning against the compliance constraints this app has to satisfy
(F-Droid, Play Store, GPL-3.0):
already
GPL-3.0-or-laterlike every other file here. (2) would also be fine license-wise(MIT is one-directionally GPL-compatible, same relationship already relied on for Apache-2.0
AndroidX deps in
docs/fdroid-compliance.md's license table), but (3) requires auditing whateverJS editor is bundled and staying strictly on its free-as-in-freedom core (avoiding "open-core"
editors with a separate proprietary tier).
docs/fdroid-compliance.md's audit. (3) is only viable if the JS/CSS is bundled inassets/and loaded via
file:///android_asset/, never fetched from a CDN at runtime (F-Droid'santi-feature policy is specifically about apps that download executable code over the network).
A native Compose text field ((1) or (2)) inherits that for free; WebView-hosted
contenteditable((3)) is the weakest of the three on this axis.Went with (1) over (2) mainly because the exact model needed (paragraph alignment, inline
cid:images, a message-wide base font, per-run parameterized styles) is specific to how this app's
outbox/reply-quoting/draft-persistence code already works (see #37's
SmtpSender/GraphSender,data/ReplyBuilder.kt) — building it in-repo means the model and the HTML shape it produces areexactly what those consumers need, with no impedance mismatch to a library's own opinions.
Revisit (2) only if the hand-rolled parser/editor starts costing more maintenance time than
adopting a library would — not preemptively.
This is already substantially implemented on
main(see Scope below) — this section documentswhy, so the decision doesn't get silently re-litigated later.
Scope
ComposeScreenwith a formatting toolbar — done:ui/compose/RichTextEditor.kt'sRichTextBodyField/FormattingToolbar, wired intoComposeScreen.kt(~line 183). Bold/italic/underline, bulleted + numbered lists, block quote,and link are all present (
format_bold…format_link_*strings). Headings were only"consider" in the original scope and are not implemented — see the gap below for the
decision this ticket still needs.
richtext/RichText.kt(RichTextContent, everyRichStylevariant,RichAlignment,RichImage,RichBaseStyle,RichLink) +richtext/RichTextHtml.kt/RichTextHtmlParser.kt(hand-written round-trip, no parsing library). Model-layer tests exist:RichTextEditingTest.kt,RichTextHtmlTest.kt.implemented (
RichTextContent.hasFormatting()gates HTML emission so an unformatted messagestays plaintext-only; toolbar buttons carry
contentDescription/onClickLabel) but not yetverified by any test — see the gap below.
OutlinedTextFieldin
ComposeScreen.ktandRichTextBodyFieldalready uses a roundedshape.Remaining gaps
H1/H2-style block marker (mirroring the existing bullet/numbered/quote
BlockMarkerpatternin
richtext/RichTextEditing.kt) or explicitly drop it from scope so it stops being anambiguous "consider" item. Low priority either way — call it out so it's a decision, not an
oversight.
RichTextEditor.kt. Everything tested so far(
RichTextEditingTest.kt,RichTextHtmlTest.kt) exercises the underlying model(
RichTextEditing,RichTextHtml) — nothing tests the Compose-editor glue inRichTextEditor.ktitself:applyStyle/applyBlock/applyLink,AnnotatedString.toRichContent()/RichTextContent.toAnnotatedString(), orFormattingToolbar's active/inactive toggle state. Theseinternalfunctions operate onTextFieldValue/AnnotatedString, which are plain Kotlin types usable directly in a JVM unittest — add a
RichTextEditorTest.ktalongside the existing two. Separately, add an androidTest(
ComposeScreenTest.kt) that taps a toolbar button and asserts the resulting content — this isthe only way to actually verify the KDoc's accessibility claim (content descriptions, toggle
state) rather than assume it.
Acceptance criteria
is confirmed:
SmtpSenderTest.kt(GreenMail-backed) verifies a formatted message sends asmultipart/alternative(nested insidemultipart/mixedwhen there's also an attachment), andComposeViewModelTest.ktconfirmsbodyHtmlstaysnullwhen nothing is formatted and carriesthe right HTML when it is. So this ticket is not blocked on #37 for closing.
(
hasFormatting()gate) but not yet covered by a test at the editor layer; folded into the gapabove.
Relevant files
ui/compose/ComposeScreen.kt,ui/compose/ComposeViewModel.kt,ui/compose/RichTextEditor.kt,richtext/RichText.kt,richtext/RichTextEditing.kt,richtext/RichTextHtml.kt,richtext/RichTextHtmlParser.kt.Notes
Pairs with #37 (send path — confirmed implemented/tested, see above); folds in #23 (done).