feat(messaging): inline markdown styling (iOS parity) (#6234)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-11 21:53:00 -05:00
committed by GitHub
parent 490830cb5a
commit 860f906c62
35 changed files with 2108 additions and 49 deletions

View File

@@ -41,5 +41,5 @@ These are specific to the Copilot CLI environment and are not covered in AGENTS.
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at
specs/20260521-153452-car-app-library-integration/plan.md
specs/20260711-153545-message-markdown-styling/plan.md
<!-- SPECKIT END -->

View File

@@ -687,6 +687,12 @@ fixed_pin
fixed_position
flip_screen
for_more_information_see_our_privacy_policy
### FORMAT ###
format_bold
format_code
format_italic
format_link
format_strikethrough
fr_HT
free_memory
free_memory_description
@@ -775,6 +781,10 @@ indoor_air_quality_iaq
info
input_channel_url
input_shared_contact_url
insert
insert_link_message
insert_link_title
insert_link_url_hint
installed_firmware_version
internal
interval_always_on

View File

@@ -1,3 +1,3 @@
{
"feature_directory": "specs/20260513-075218-lockdown-mode"
"feature_directory": "specs/20260711-153545-message-markdown-styling"
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M320,720L80,480L320,240L377,297L193,481L376,664L320,720ZM640,720L583,663L767,479L584,296L640,240L880,480L640,720Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M272,760L272,200L493,200Q558,200 613,240Q668,280 668,351Q668,402 645,429.5Q622,457 602,469L602,469Q627,480 657.5,510Q688,540 688,600Q688,689 623,724.5Q558,760 501,760L272,760ZM393,648L497,648Q545,648 555.5,623.5Q566,599 566,588Q566,577 555.5,552.5Q545,528 494,528L393,528L393,648ZM393,420L486,420Q519,420 534,403Q549,386 549,365Q549,341 532,326Q515,311 488,311L393,311L393,420Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M200,760L200,660L360,660L480,300L320,300L320,200L720,200L720,300L580,300L460,660L600,660L600,760L200,760Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M80,560L80,480L880,480L880,560L80,560ZM420,400L420,280L200,280L200,160L760,160L760,280L540,280L540,400L420,400ZM420,800L420,640L540,640L540,800L420,800Z"/>
</vector>

View File

@@ -711,6 +711,12 @@
<string name="fixed_position">Fixed Position</string>
<string name="flip_screen">Flip screen</string>
<string name="for_more_information_see_our_privacy_policy">For more information, see our privacy policy.</string>
<!-- FORMAT -->
<string name="format_bold">Bold</string>
<string name="format_code">Code</string>
<string name="format_italic">Italic</string>
<string name="format_link">Link</string>
<string name="format_strikethrough">Strikethrough</string>
<string name="fr_HT" translatable="false">Kreyòl ayisyen</string>
<string name="free_memory">Free Memory</string>
<string name="free_memory_description">Available system memory in bytes</string>
@@ -799,6 +805,10 @@
<string name="info">Information</string>
<string name="input_channel_url">Input Shared Channels URL</string>
<string name="input_shared_contact_url">Input Shared Contact URL</string>
<string name="insert">Insert</string>
<string name="insert_link_message">Enter the URL for the selected text</string>
<string name="insert_link_title">Insert Link</string>
<string name="insert_link_url_hint">https://</string>
<string name="installed_firmware_version">Currently Installed</string>
<string name="internal">Internal</string>
<string name="interval_always_on">Always On</string>

View File

@@ -49,6 +49,7 @@ kotlin {
api(libs.compose.multiplatform.ui.tooling.preview)
implementation(libs.coil)
implementation(libs.jetbrains.markdown)
implementation(libs.kermit)
implementation(libs.kotlinx.serialization.json)
implementation(libs.koin.compose.viewmodel)

View File

@@ -30,6 +30,8 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
@@ -85,39 +87,65 @@ fun AutoLinkText(
Text(text = annotatedString, modifier = modifier, style = style.copy(color = color), textAlign = textAlign)
}
private fun buildAnnotatedStringWithLinks(
private const val MARKDOWN_DELIMITERS = "*~`["
private data class MentionReplacement(val start: Int, val oldLength: Int, val newLength: Int)
// Maps an offset in the parsed (markdown-stripped) text to the final display text after mention substitution:
// every mention replacement that ends at or before the offset shifts it by its length delta. A replacement that
// falls strictly inside a span (start before, end after) shifts only the span's end, so the span grows to cover
// the substituted display name.
private fun Int.shiftedBy(replacements: List<MentionReplacement>): Int {
var delta = 0
for (replacement in replacements) {
if (replacement.start + replacement.oldLength <= this) delta += replacement.newLength - replacement.oldLength
}
return this + delta
}
private fun IntRange.shiftedBy(replacements: List<MentionReplacement>): IntRange =
first.shiftedBy(replacements) until (last + 1).shiftedBy(replacements)
private fun InlineStyle.toSpanStyle(): SpanStyle = when (this) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
InlineStyle.Strikethrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
InlineStyle.Code -> SpanStyle(fontFamily = FontFamily.Monospace)
InlineStyle.Link -> SpanStyle()
}
// Interleaves four span sources (mentions, markdown styles, markdown links, bare autolinks) into one AnnotatedString;
// the per-source application loops keep it just over the default complexity threshold.
@Suppress("CyclomaticComplexMethod")
internal fun buildAnnotatedStringWithLinks(
text: String,
linkStyles: TextLinkStyles,
mentionName: ((String) -> String?)?,
onMentionClick: (String) -> Unit,
): AnnotatedString {
// Substitute each mention token with its live display name up front, tracking display-space ranges.
// URL/email/phone detection then runs over the substituted text so every offset lines up.
val mentions = mutableListOf<Pair<IntRange, String>>() // display range -> "!hex" id
val display =
if (mentionName == null) {
text
// 1) Inline markdown over the raw text. Mention tokens (`@!<hex>`) contain no markdown delimiters, so they
// survive parsing untouched. Skip parsing when there is no delimiter at all — this also leaves emoji-only
// messages (and plain text) unchanged (FR-006) and avoids the parser on the common case.
val parsed =
if (text.any { it in MARKDOWN_DELIMITERS }) {
parseInlineMarkdown(text)
} else {
buildString {
var cursor = 0
for (match in MENTION_TOKEN_REGEX.findAll(text)) {
append(text, cursor, match.range.first)
val id = match.groupValues[1]
val start = length
append("@").append(mentionName(id) ?: id)
mentions.add((start until length) to id)
cursor = match.range.last + 1
}
append(text, cursor, text.length)
}
InlineMarkdownResult(text, emptyList(), emptyList())
}
// 2) Substitute each mention token with its live display name, then shift the markdown spans so they stay aligned.
val substitution = substituteMentions(parsed.displayText, mentionName)
val display = substitution.display
val styleSpans = parsed.styleSpans.map { it.range.shiftedBy(substitution.replacements) to it.style }
val linkSpans = parsed.linkSpans.map { it.range.shiftedBy(substitution.replacements) to it.url }
val autoLinks = collectAutolinkMatches(display)
return buildAnnotatedString {
append(display)
val usedIndices = mutableSetOf<Int>()
for ((range, id) in mentions) {
for ((range, id) in substitution.mentions) {
addLink(
LinkAnnotation.Clickable(tag = "mention", styles = TextLinkStyles(MentionSpanStyle)) {
onMentionClick(id)
@@ -128,30 +156,73 @@ private fun buildAnnotatedStringWithLinks(
range.forEach { usedIndices.add(it) }
}
val matches = mutableListOf<Pair<IntRange, String>>()
WEB_URL_REGEX.findAll(display).forEach { match ->
val url = match.value
val fullUrl = if (url.startsWith("www.", ignoreCase = true)) "https://$url" else url
matches.add(match.range to fullUrl)
// Markdown character styles (bold/italic/strikethrough/code) — may overlap mentions harmlessly.
for ((range, style) in styleSpans) {
val end = range.last + 1
if (range.first in display.indices && range.first < end && end <= display.length) {
addStyle(style.toSpanStyle(), range.first, end)
}
}
EMAIL_REGEX.findAll(display).forEach { match -> matches.add(match.range to "mailto:${match.value}") }
// Markdown links (`[label](url)`) take precedence over bare-URL autolinking of the same span.
for ((range, url) in linkSpans) {
if (range.first !in display.indices || range.any { it in usedIndices }) continue
addLink(LinkAnnotation.Url(url = url, styles = linkStyles), range.first, range.last + 1)
range.forEach { usedIndices.add(it) }
}
PHONE_REGEX.findAll(display).forEach { match -> matches.add(match.range to "tel:${match.value}") }
// Sort by start position, then by length (longer first)
val sortedMatches = matches.sortedWith(compareBy({ it.first.first }, { -(it.first.last - it.first.first) }))
for ((range, url) in sortedMatches) {
// Bare URL/email/phone autolinking, skipping anything already covered by a mention or markdown link.
for ((range, url) in autoLinks) {
if (range.any { it in usedIndices }) continue
addLink(LinkAnnotation.Url(url = url, styles = linkStyles), range.first, range.last + 1)
range.forEach { usedIndices.add(it) }
}
}
}
/** Result of mention substitution: the substituted [display] text, its mention ranges, and the length deltas. */
private data class MentionSubstitution(
val display: String,
val mentions: List<Pair<IntRange, String>>,
val replacements: List<MentionReplacement>,
)
/** Replaces `@!<hex>` tokens in [source] with their live display names, recording ranges and length deltas. */
private fun substituteMentions(source: String, mentionName: ((String) -> String?)?): MentionSubstitution {
if (mentionName == null) return MentionSubstitution(source, emptyList(), emptyList())
val mentions = mutableListOf<Pair<IntRange, String>>()
val replacements = mutableListOf<MentionReplacement>()
val display = buildString {
var cursor = 0
for (match in MENTION_TOKEN_REGEX.findAll(source)) {
append(source, cursor, match.range.first)
val id = match.groupValues[1]
val name = "@" + (mentionName(id) ?: id)
val start = length
append(name)
mentions.add((start until length) to id)
replacements.add(
MentionReplacement(match.range.first, match.range.last - match.range.first + 1, name.length),
)
cursor = match.range.last + 1
}
append(source, cursor, source.length)
}
return MentionSubstitution(display, mentions, replacements)
}
/** Collects bare URL/email/phone matches in [display], sorted by start then longest-first. */
private fun collectAutolinkMatches(display: String): List<Pair<IntRange, String>> {
val matches = mutableListOf<Pair<IntRange, String>>()
WEB_URL_REGEX.findAll(display).forEach { match ->
val url = match.value
matches.add(match.range to if (url.startsWith("www.", ignoreCase = true)) "https://$url" else url)
}
EMAIL_REGEX.findAll(display).forEach { match -> matches.add(match.range to "mailto:${match.value}") }
PHONE_REGEX.findAll(display).forEach { match -> matches.add(match.range to "tel:${match.value}") }
return matches.sortedWith(compareBy({ it.first.first }, { -(it.first.last - it.first.first) }))
}
/**
* A [Text] component that highlights occurrences of [query] within [text] using the tertiary container color. Each
* matching token in the query is highlighted independently (case-insensitive).

View File

@@ -0,0 +1,195 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
import org.intellij.markdown.parser.MarkdownParser
/**
* Parses the lightweight **inline** markdown subset shared with the iOS client — `**bold**`, `*italic*`,
* `~~strikethrough~~`, `` `code` `` and `[label](url)` — into a delimiter-stripped [InlineMarkdownResult.displayText]
* plus display-space [StyleSpan]s and [LinkSpan]s.
*
* Inline-only, matching iOS `AttributedString(markdown: .inlineOnlyPreservingWhitespace)`: block constructs (headings,
* lists, block quotes, fenced code, tables, images) are NOT interpreted — their markers survive as literal text because
* only the delimiter tokens that are direct children of a recognised inline element are removed. Whitespace and
* newlines are preserved. Malformed or unpaired markup degrades to literal text and never throws.
*
* The result is consumed by [AutoLinkText], which layers `@mention` and bare URL/email/phone links over the same
* [InlineMarkdownResult.displayText].
*/
fun parseInlineMarkdown(source: String): InlineMarkdownResult {
// Empty input or a parser failure (assertions are disabled, but stay total regardless) → literal text, no spans.
val tree = if (source.isEmpty()) null else runCatching { parser.buildMarkdownTreeFromString(source) }.getOrNull()
return tree?.let { InlineMarkdownVisitor(source).build(it) }
?: InlineMarkdownResult(source, emptyList(), emptyList())
}
// assertionsEnabled = false → the parser recovers to a flat tree instead of throwing on malformed input.
private val parser = MarkdownParser(GFMFlavourDescriptor(), false)
/**
* Style spans over the RAW [source] (delimiters kept in place) for live in-field styling. Unlike [parseInlineMarkdown]
* this does NOT strip delimiters — it reports each bold/italic/strikethrough/code element's raw content range so a
* text-field `OutputTransformation` can `addStyle` without changing the stored text. Links are not styled here.
*/
fun inlineMarkdownStyleRanges(source: String): List<StyleSpan> {
val hasDelimiter = source.any { it == '*' || it == '~' || it == '`' }
val tree =
(if (hasDelimiter) runCatching { parser.buildMarkdownTreeFromString(source) }.getOrNull() else null)
?: return emptyList()
val spans = mutableListOf<StyleSpan>()
collectStyleRanges(tree, spans)
return spans
}
private fun collectStyleRanges(node: ASTNode, spans: MutableList<StyleSpan>) {
val style =
when (node.type) {
MarkdownElementTypes.STRONG -> InlineStyle.Bold
MarkdownElementTypes.EMPH -> InlineStyle.Italic
MarkdownElementTypes.CODE_SPAN -> InlineStyle.Code
GFMElementTypes.STRIKETHROUGH -> InlineStyle.Strikethrough
else -> null
}
if (style != null) {
val content = node.children.filter { !(it.children.isEmpty() && it.type in delimiterTokens) }
val start = content.firstOrNull()?.startOffset
val end = content.lastOrNull()?.endOffset
if (start != null && end != null && end > start) spans.add(StyleSpan(start until end, style))
}
node.children.forEach { collectStyleRanges(it, spans) }
}
/** The five inline styles shared with iOS. */
enum class InlineStyle {
Bold,
Italic,
Strikethrough,
Code,
Link,
}
/** A non-link inline style applied over a half-open [range] in display-space. */
data class StyleSpan(val range: IntRange, val style: InlineStyle)
/** A markdown link covering the display-space [range] of its label, pointing at [url]. */
data class LinkSpan(val range: IntRange, val url: String)
/** Output of [parseInlineMarkdown]: the stripped text plus spans, all in display-space offsets. */
data class InlineMarkdownResult(val displayText: String, val styleSpans: List<StyleSpan>, val linkSpans: List<LinkSpan>)
/** Delimiter tokens removed when they are direct children of a recognised inline element. */
private val delimiterTokens =
setOf(
MarkdownTokenTypes.EMPH,
MarkdownTokenTypes.BACKTICK,
MarkdownTokenTypes.ESCAPED_BACKTICKS,
GFMTokenTypes.TILDE,
)
private class InlineMarkdownVisitor(private val source: String) {
private val out = StringBuilder()
private val styleSpans = mutableListOf<StyleSpan>()
private val linkSpans = mutableListOf<LinkSpan>()
fun build(root: ASTNode): InlineMarkdownResult {
visit(root, emptySet())
return InlineMarkdownResult(out.toString(), styleSpans.toList(), linkSpans.toList())
}
private fun visit(node: ASTNode, styles: Set<InlineStyle>) {
when (node.type) {
MarkdownElementTypes.STRONG -> visitStyled(node, styles, InlineStyle.Bold)
MarkdownElementTypes.EMPH -> visitStyled(node, styles, InlineStyle.Italic)
GFMElementTypes.STRIKETHROUGH -> visitStyled(node, styles, InlineStyle.Strikethrough)
MarkdownElementTypes.CODE_SPAN -> visitCodeSpan(node, styles)
MarkdownElementTypes.INLINE_LINK -> visitLink(node, styles)
// Images are not interpreted (inline-only): render the raw source literally.
MarkdownElementTypes.IMAGE -> emitRaw(node, styles)
else -> if (node.children.isEmpty()) emitRaw(node, styles) else node.children.forEach { visit(it, styles) }
}
}
private fun visitStyled(node: ASTNode, styles: Set<InlineStyle>, style: InlineStyle) {
val next = styles + style
node.children.forEach { child ->
// Skip the leaf open/close markers; recurse everything else (content may itself be styled/nested).
if (child.children.isEmpty() && child.type in delimiterTokens) return@forEach
visit(child, next)
}
}
private fun visitCodeSpan(node: ASTNode, styles: Set<InlineStyle>) {
// A code span's content is literal and never re-interpreted, so emit everything between the backtick runs
// as ONE contiguous chunk — the lexer may split the inner text (e.g. a literal `*`) into several tokens,
// but it must render as a single monospace span.
val content =
node.children.filter {
it.type != MarkdownTokenTypes.BACKTICK && it.type != MarkdownTokenTypes.ESCAPED_BACKTICKS
}
val startOffset = content.firstOrNull()?.startOffset ?: return
val endOffset = content.last().endOffset
val start = out.length
out.append(source, startOffset, endOffset)
val end = out.length
if (end > start) {
(styles + InlineStyle.Code).forEach { style ->
if (style != InlineStyle.Link) styleSpans.add(StyleSpan(start until end, style))
}
}
}
private fun visitLink(node: ASTNode, styles: Set<InlineStyle>) {
val linkText = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT }
val destination = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
if (linkText == null || destination == null) {
// Malformed link — render literally.
if (node.children.isEmpty()) emitRaw(node, styles) else node.children.forEach { visit(it, styles) }
return
}
val url = source.substring(destination.startOffset, destination.endOffset)
val start = out.length
linkText.children.forEach { child ->
// LINK_TEXT wraps its content in `[` … `]`; drop the brackets, keep (and style) the label.
if (child.type == MarkdownTokenTypes.LBRACKET || child.type == MarkdownTokenTypes.RBRACKET) return@forEach
visit(child, styles)
}
val end = out.length
if (end > start) linkSpans.add(LinkSpan(start until end, url))
}
private fun emitRaw(node: ASTNode, styles: Set<InlineStyle>) {
val start = out.length
out.append(source, node.startOffset, node.endOffset)
val end = out.length
if (end > start) {
styles.forEach { style -> if (style != InlineStyle.Link) styleSpans.add(StyleSpan(start until end, style)) }
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_code
import org.meshtastic.core.resources.ic_format_bold
import org.meshtastic.core.resources.ic_format_italic
import org.meshtastic.core.resources.ic_format_strikethrough
import org.meshtastic.core.resources.ic_link
val MeshtasticIcons.Bold: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_format_bold)
val MeshtasticIcons.Italic: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_format_italic)
val MeshtasticIcons.Strikethrough: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_format_strikethrough)
val MeshtasticIcons.Code: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_code)
val MeshtasticIcons.Link: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_link)

View File

@@ -0,0 +1,146 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Contract tests for the render extension in [buildAnnotatedStringWithLinks] (see contracts/autolinktext-render.md,
* rows A-1…A-8). These assert on the produced [AnnotatedString] directly — the builder is a pure function, so no
* Compose UI harness is required.
*/
class AutoLinkTextRenderTest {
private val linkStyles = TextLinkStyles(style = SpanStyle())
private fun build(text: String, mentionName: ((String) -> String?)? = null): AnnotatedString =
buildAnnotatedStringWithLinks(text, linkStyles, mentionName) {}
private fun AnnotatedString.urlLinks(): List<Triple<String, Int, Int>> =
getLinkAnnotations(0, length).mapNotNull { range ->
(range.item as? LinkAnnotation.Url)?.let { Triple(it.url, range.start, range.end) }
}
private fun AnnotatedString.clickableTags(): List<AnnotatedString.Range<LinkAnnotation.Clickable>> =
getLinkAnnotations(0, length).mapNotNull { range ->
(range.item as? LinkAnnotation.Clickable)?.let { AnnotatedString.Range(it, range.start, range.end) }
}
@Test // A-1
fun bold_renders_without_delimiters() {
val result = build("**bold**")
assertEquals("bold", result.text)
val bold = result.spanStyles.single { it.item.fontWeight == FontWeight.Bold }
assertEquals(0, bold.start)
assertEquals(4, bold.end)
assertTrue(result.urlLinks().isEmpty())
}
@Test // A-2
fun markdown_link_is_tappable_and_syntax_removed() {
val result = build("[label](https://e.com)")
assertEquals("label", result.text)
val link = result.urlLinks().single()
assertEquals("https://e.com", link.first)
assertEquals("label", result.text.substring(link.second, link.third))
}
@Test // A-3
fun bare_url_is_autolinked() {
val result = build("see https://e.com")
assertEquals("see https://e.com", result.text)
val link = result.urlLinks().single()
assertEquals("https://e.com", link.first)
assertEquals("https://e.com", result.text.substring(link.second, link.third))
}
@Test // A-4
fun markdown_link_and_bare_url_both_link_without_double_linking() {
val result = build("[x](https://e.com) and https://e.com")
assertEquals("x and https://e.com", result.text)
val links = result.urlLinks().sortedBy { it.second }
assertEquals(2, links.size)
// Markdown link on "x".
assertEquals("x", result.text.substring(links[0].second, links[0].third))
assertEquals("https://e.com", links[0].first)
// Bare URL is linked separately, no overlap with the markdown link range.
assertEquals("https://e.com", result.text.substring(links[1].second, links[1].third))
assertTrue(links[0].third <= links[1].second, "link ranges must not overlap")
}
@Test // A-5
fun mention_inside_bold_resolves_and_keeps_offsets() {
val result = build("**@!abcd1234**") { id -> if (id == "!abcd1234") "Alice" else null }
assertEquals("@Alice", result.text)
// The markdown bold must cover the whole substituted display name. The parser tokenizes punctuation (`@`, `!`)
// into separate leaf nodes, so the bold can arrive as several adjacent fragments; they render additively, so
// coverage — not span count — is the contract. Every index of "@Alice" must fall inside some bold span whose
// offsets grew with the mention substitution.
val boldSpans = result.spanStyles.filter { it.item.fontWeight == FontWeight.Bold }
assertTrue(
"@Alice".indices.all { i -> boldSpans.any { i in it.start until it.end } },
"expected bold to cover the whole display name; text='${result.text}' " +
"boldSpans=${boldSpans.map { it.start to it.end }}",
)
// Mention is a tappable clickable spanning the display name.
val mention = result.clickableTags().single()
assertEquals(0, mention.start)
assertEquals("@Alice".length, mention.end)
// The bold delimiters must not have produced a bare-URL autolink.
assertTrue(result.urlLinks().isEmpty())
}
@Test // A-6
fun plain_text_falls_back_to_autolinked_text_only() {
val result = build("no markdown here")
assertEquals("no markdown here", result.text)
assertTrue(result.spanStyles.isEmpty())
assertTrue(result.urlLinks().isEmpty())
}
@Test // A-6 (unpaired delimiter degrades to literal, no crash)
fun unpaired_delimiter_renders_literally() {
val result = build("price is 3 * 4 = 12")
assertEquals("price is 3 * 4 = 12", result.text)
assertTrue(result.spanStyles.none { it.item.fontStyle == FontStyle.Italic })
}
@Test // A-8
fun emoji_only_short_circuits_and_renders_unchanged() {
val result = build("😀👍")
assertEquals("😀👍", result.text)
assertTrue(result.spanStyles.isEmpty())
assertTrue(result.urlLinks().isEmpty())
}
@Test // A-5 supporting: mention without a resolver keeps the raw id token
fun mention_without_resolver_is_left_untouched() {
val result = build("hi @!abcd1234")
// No mentionName callback → substituteMentions is a no-op, token stays literal, no clickable is added.
assertEquals("hi @!abcd1234", result.text)
assertNull(result.clickableTags().singleOrNull())
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Contract tests for [parseInlineMarkdown] (see contracts/inline-markdown-parser.md, rows P-1…P-11). */
class InlineMarkdownTest {
private fun styledText(result: InlineMarkdownResult, style: InlineStyle): List<String> =
result.styleSpans.filter { it.style == style }.map { result.displayText.substring(it.range) }
@Test // P-1
fun bold_strips_delimiters_and_styles_content() {
val result = parseInlineMarkdown("a **b** c")
assertEquals("a b c", result.displayText)
assertEquals(listOf("b"), styledText(result, InlineStyle.Bold))
}
@Test // P-2
fun single_asterisk_is_italic_not_bold() {
val result = parseInlineMarkdown("a *b* c")
assertEquals("a b c", result.displayText)
assertEquals(listOf("b"), styledText(result, InlineStyle.Italic))
assertTrue(styledText(result, InlineStyle.Bold).isEmpty())
}
@Test // P-3
fun triple_asterisk_is_bold_and_italic() {
val result = parseInlineMarkdown("a ***b*** c")
assertEquals("a b c", result.displayText)
assertEquals(listOf("b"), styledText(result, InlineStyle.Bold))
assertEquals(listOf("b"), styledText(result, InlineStyle.Italic))
}
@Test // P-4
fun strikethrough_strips_and_styles() {
val result = parseInlineMarkdown("a ~~b~~ c")
assertEquals("a b c", result.displayText)
assertEquals(listOf("b"), styledText(result, InlineStyle.Strikethrough))
}
@Test // P-5
fun code_span_does_not_reinterpret_inner_markup() {
val result = parseInlineMarkdown("a `b*c` d")
assertEquals("a b*c d", result.displayText)
assertEquals(listOf("b*c"), styledText(result, InlineStyle.Code))
assertTrue(styledText(result, InlineStyle.Italic).isEmpty())
}
@Test // P-6
fun inline_link_strips_syntax_and_records_url() {
val result = parseInlineMarkdown("[x](https://e.com)")
assertEquals("x", result.displayText)
assertEquals(1, result.linkSpans.size)
val link = result.linkSpans.single()
assertEquals("x", result.displayText.substring(link.range))
assertEquals("https://e.com", link.url)
}
@Test // P-7
fun unpaired_delimiter_is_literal_and_does_not_throw() {
val result = parseInlineMarkdown("**oops")
assertEquals("**oops", result.displayText)
assertTrue(result.styleSpans.isEmpty())
}
@Test // P-7 (loose asterisks around words)
fun loose_asterisks_render_literally() {
val result = parseInlineMarkdown("a * b * c")
assertEquals("a * b * c", result.displayText)
}
@Test // P-8
fun block_syntax_renders_literally() {
val result = parseInlineMarkdown("# heading\n- item")
assertEquals("# heading\n- item", result.displayText)
}
@Test // P-9
fun newlines_are_preserved() {
val result = parseInlineMarkdown("line1\nline2")
assertEquals("line1\nline2", result.displayText)
}
@Test // P-10
fun empty_input_yields_empty_result() {
val result = parseInlineMarkdown("")
assertEquals("", result.displayText)
assertTrue(result.styleSpans.isEmpty())
assertTrue(result.linkSpans.isEmpty())
}
@Test // P-11 (emoji-only carries no delimiters and is unchanged)
fun emoji_only_is_unchanged() {
val result = parseInlineMarkdown("😀👍")
assertEquals("😀👍", result.displayText)
assertTrue(result.styleSpans.isEmpty())
}
@Test
fun mixed_message_strips_all_delimiters() {
val result =
parseInlineMarkdown(
"Meet at **noon** by the ~~old~~ new *bridge* — `README` and [map](https://example.com)",
)
assertEquals("Meet at noon by the old new bridge — README and map", result.displayText)
assertEquals(listOf("noon"), styledText(result, InlineStyle.Bold))
assertEquals(listOf("old"), styledText(result, InlineStyle.Strikethrough))
assertEquals(listOf("bridge"), styledText(result, InlineStyle.Italic))
assertEquals(listOf("README"), styledText(result, InlineStyle.Code))
assertEquals("https://example.com", result.linkSpans.single().url)
assertEquals("map", result.displayText.substring(result.linkSpans.single().range))
}
}

View File

@@ -2,7 +2,7 @@
title: Messages & Channels
parent: User Guide
nav_order: 3
last_updated: 2026-07-08
last_updated: 2026-07-11
description: Send and receive messages, manage channels, configure encryption, search conversations, and use quick chat, reactions, and message actions.
aliases:
- channels
@@ -123,6 +123,22 @@ You can search the full history of any conversation directly from the chat scree
Messages appear as chat bubbles — sent messages on the right, received messages on the left. Each bubble shows the sender, timestamp, and delivery status. Messages with replies include a quoted preview of the original message above the response.
### Text Formatting
Messages support lightweight inline **Markdown**. Received messages render the styling with the syntax characters removed:
| Type | Syntax | Renders as |
|------|--------|------------|
| Bold | `**bold**` | **bold** |
| Italic | `*italic*` | *italic* |
| Strikethrough | `~~strike~~` | ~~strike~~ |
| Inline code | `` `code` `` | monospace `code` |
| Link | `[label](https://example.com)` | a tappable **label** |
When composing, focus the message field and type at least three characters to reveal a **formatting toolbar** below the input. Select text and tap a style to wrap it (tap again to remove it); with no selection, a style inserts an empty pair with the cursor between the markers. The link button opens a dialog to enter a URL. As you type, the draft styles live in the field while the underlying text keeps its Markdown characters.
> 💡 **Tip:** Formatting is carried as literal characters on the mesh — the same bytes iOS sends. Clients that don't support Markdown (older apps, plain firmware clients) will show the raw `**`/`~~` characters. URLs, email addresses, and phone numbers are still auto-linked whether or not you use Markdown.
### Mentions
Type `@` while composing to mention a node — a picker suggests matching contacts as you type. In a received message, a mention appears as a highlighted chip showing the node's name; tap it to jump straight to that node's detail page.

View File

@@ -19,6 +19,7 @@
package org.meshtastic.feature.messaging
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -58,6 +59,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
@@ -66,11 +68,15 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
@@ -91,7 +97,9 @@ import org.meshtastic.core.resources.message_input_label
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.type_a_message
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.InlineStyle
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.inlineMarkdownStyleRanges
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Send
@@ -99,6 +107,7 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.FormattingToolbar
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
import org.meshtastic.feature.messaging.component.MessageMenuAction
import org.meshtastic.feature.messaging.component.MessageSearchBar
@@ -111,6 +120,9 @@ import org.meshtastic.feature.messaging.component.TranslationModelDownloadDialog
private const val ROUNDED_CORNER_PERCENT = 100
private const val MAX_LINES = 3
// Minimum draft length before the markdown formatting toolbar appears (matches the iOS client).
private const val FORMATTING_TOOLBAR_MIN_CHARS = 3
/**
* The main screen for displaying and sending messages to a contact or channel.
*
@@ -527,13 +539,29 @@ private fun Node.matchesMention(query: String): Boolean {
user.id.lowercase().contains(q)
}
/** Displays `@!<hex>` tokens as `@FriendlyName` while typing; the stored buffer keeps the hex wire form. */
/**
* Displays `@!<hex>` tokens as `@FriendlyName` and applies live inline-markdown styling (bold/italic/strikethrough/
* code) while typing. Both are presentation-only via [OutputTransformation]: the stored buffer keeps the hex wire form
* and the raw markdown delimiters, so the bytes sent are unchanged.
*/
@OptIn(ExperimentalFoundationApi::class)
private fun mentionOutputTransformation(nodesById: Map<String, Node>) = OutputTransformation {
val source = toString()
for (match in MENTION_TOKEN_REGEX.findAll(source).toList().asReversed()) {
for (match in MENTION_TOKEN_REGEX.findAll(toString()).toList().asReversed()) {
val node = nodesById[match.groupValues[1]] ?: continue
replace(match.range.first, match.range.last + 1, "@" + node.user.long_name.ifEmpty { node.user.short_name })
}
// Live markdown styling over the (mention-substituted) buffer. Delimiters stay visible; only spans are added.
for (span in inlineMarkdownStyleRanges(toString())) {
val spanStyle =
when (span.style) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
InlineStyle.Strikethrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
InlineStyle.Code -> SpanStyle(fontFamily = FontFamily.Monospace)
InlineStyle.Link -> continue
}
addStyle(spanStyle, span.range.first, span.range.last + 1)
}
}
/**
@@ -605,21 +633,26 @@ private fun MessageInput(
}
}
var isFocused by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
if (mentionActive) {
MentionSuggestions(suggestions = suggestions, onPick = ::insertMention)
}
OutlinedTextField(
modifier =
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).onKeyEvent { keyEvent ->
val isEnterNoShift = keyEvent.key == Key.Enter && !keyEvent.isShiftPressed
if (isEnterNoShift) {
if (keyEvent.type == KeyEventType.KeyUp) onSendAction()
true // consume both KeyDown and KeyUp to prevent newline insertion
} else {
false
}
},
Modifier.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.onKeyEvent { keyEvent ->
val isEnterNoShift = keyEvent.key == Key.Enter && !keyEvent.isShiftPressed
if (isEnterNoShift) {
if (keyEvent.type == KeyEventType.KeyUp) onSendAction()
true // consume both KeyDown and KeyUp to prevent newline insertion
} else {
false
}
},
state = textFieldState,
outputTransformation = mentionOutput,
lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
@@ -657,6 +690,10 @@ private fun MessageInput(
}
},
)
// Markdown formatting toolbar — shown once the field is focused and holds enough text to format (iOS parity).
if (isEnabled && isFocused && currentText.length >= FORMATTING_TOOLBAR_MIN_CHARS) {
FormattingToolbar(state = textFieldState, modifier = Modifier.padding(horizontal = 8.dp))
}
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging
import androidx.compose.ui.text.TextRange
import org.meshtastic.core.ui.component.InlineStyle
// Pure text-mutation helpers backing the formatting toolbar, mirroring the iOS client's MarkdownFormatting.swift so
// the two platforms produce byte-for-byte identical markup for the same selection + action. No UI-framework
// dependency beyond the multiplatform `TextRange` value type, so these run in commonTest.
/**
* Toggles [style] markup around the selection [range] of [text]: wraps an unwrapped selection, removes the delimiters
* from an already-wrapped one, and inserts an empty delimiter pair for a collapsed cursor. Whitespace is kept outside
* the delimiters, delimiter characters the selection cuts through are absorbed, and orphaned delimiters left elsewhere
* are cleaned up.
*/
@Suppress("ReturnCount") // Guard clauses (toggle-off, empty-selection) read more clearly than one nested expression.
fun wrapSelection(text: String, range: TextRange, style: InlineStyle): FormattingResult {
val opening = style.openingDelimiter()
val closing = style.closingDelimiter()
val start = range.min
val end = range.max
val hasOpeningBefore = start - opening.length >= 0 && text.substring(start - opening.length, start) == opening
val hasClosingAfter = end + closing.length <= text.length && text.substring(end, end + closing.length) == closing
if (hasOpeningBefore && hasClosingAfter) {
val delimiterStart = start - opening.length
val delimiterEnd = end + closing.length
val newText = buildString {
append(text, 0, delimiterStart)
append(text, start, end)
append(text, delimiterEnd, text.length)
}
val resultStart = delimiterStart
return FormattingResult(newText, TextRange(resultStart, resultStart + (end - start)))
}
val (expandedStart, expandedEnd) = expandToDelimiterBoundaries(text, start, end)
val cleaned = text.substring(expandedStart, expandedEnd).filter { it !in DELIMITER_CHARS }
val firstNonWs = cleaned.indexOfFirst { !it.isWhitespace() }
if (firstNonWs < 0) return insertDelimiters(text, expandedStart, style)
val lastNonWs = cleaned.indexOfLast { !it.isWhitespace() }
val leadingWs = cleaned.substring(0, firstNonWs)
val trailingWs = cleaned.substring(lastNonWs + 1)
val trimmed = cleaned.substring(firstNonWs, lastNonWs + 1)
val wrapped = leadingWs + opening + trimmed + closing + trailingWs
val newText =
(text.substring(0, expandedStart) + wrapped + text.substring(expandedEnd)).let(::cleanOrphanedDelimiters)
val fullWrapped = opening + trimmed + closing
val found = newText.indexOf(fullWrapped)
return if (found >= 0) {
FormattingResult(newText, TextRange(found, found + fullWrapped.length))
} else {
val selStart = (expandedStart + leadingWs.length).coerceIn(0, newText.length)
val selEnd = (selStart + fullWrapped.length).coerceIn(selStart, newText.length)
FormattingResult(newText, TextRange(selStart, selEnd))
}
}
/** Inserts an empty [style] delimiter pair at [index], returning a collapsed cursor between the delimiters. */
fun insertDelimiters(text: String, index: Int, style: InlineStyle): FormattingResult {
val opening = style.openingDelimiter()
val closing = style.closingDelimiter()
val newText = text.substring(0, index) + opening + closing + text.substring(index)
val cursor = index + opening.length
return FormattingResult(newText, TextRange(cursor))
}
/** Wraps the selection as `[selection](url)`, or inserts a `[link text](url)` placeholder for a collapsed cursor. */
fun wrapSelectionWithLink(text: String, range: TextRange, url: String): FormattingResult {
val start = range.min
val end = range.max
val selected = text.substring(start, end)
val replacement = if (selected.isEmpty()) "[link text]($url)" else "[$selected]($url)"
val newText = text.substring(0, start) + replacement + text.substring(end)
return FormattingResult(newText, TextRange(start, start + replacement.length))
}
/** Unwraps a `[label](url)` selection back to its label, or returns null when the selection is not a markdown link. */
fun unwrapLink(text: String, range: TextRange): FormattingResult? {
val start = range.min
val end = range.max
val match = LINK_PATTERN.find(text.substring(start, end)) ?: return null
val label = match.groupValues[1]
val newText = text.substring(0, start) + label + text.substring(end)
return FormattingResult(newText, TextRange(start, start + label.length))
}
/** True when [text] is exactly a `[label](url)` markdown link. */
fun isMarkdownLink(text: String): Boolean = LINK_PATTERN.matches(text)
/** True when [text] contains any recognised paired inline-markdown syntax. */
fun containsMarkdownSyntax(text: String): Boolean {
if (text.isEmpty()) return false
// Italic strips `**` first so a bold run isn't mistaken for italic (avoids KMP-unsafe lookbehind).
return BOLD_PATTERN.containsMatchIn(text) ||
ITALIC_PATTERN.containsMatchIn(text.replace("**", "")) ||
STRIKE_PATTERN.containsMatchIn(text) ||
CODE_PATTERN.containsMatchIn(text) ||
INLINE_LINK_PATTERN.containsMatchIn(text)
}
/** New raw text plus the selection to apply to the field after a formatting action. */
data class FormattingResult(val text: String, val selection: TextRange)
private val DELIMITER_CHARS = setOf('*', '~', '`')
private val LINK_PATTERN = Regex("^\\[([^\\]]+)\\]\\(([^)]+)\\)$")
private val BOLD_PATTERN = Regex("\\*\\*[^*]+\\*\\*")
private val ITALIC_PATTERN = Regex("\\*[^*]+\\*")
private val STRIKE_PATTERN = Regex("~~[^~]+~~")
private val CODE_PATTERN = Regex("`[^`]+`")
private val INLINE_LINK_PATTERN = Regex("\\[[^\\]]+\\]\\([^)]+\\)")
private fun InlineStyle.openingDelimiter(): String = when (this) {
InlineStyle.Bold -> "**"
InlineStyle.Italic -> "*"
InlineStyle.Strikethrough -> "~~"
InlineStyle.Code -> "`"
InlineStyle.Link -> "["
}
private fun InlineStyle.closingDelimiter(): String = if (this == InlineStyle.Link) "]" else openingDelimiter()
/**
* Expands [start]..[end] outward across any contiguous delimiter characters they touch, so wrapping never leaves a
* half-delimiter behind.
*/
private fun expandToDelimiterBoundaries(text: String, start: Int, end: Int): Pair<Int, Int> {
val insideHasDelimiter = text.substring(start, end).any { it in DELIMITER_CHARS }
val beforeIsDelimiter = start > 0 && text[start - 1] in DELIMITER_CHARS
val afterIsDelimiter = end < text.length && text[end] in DELIMITER_CHARS
if (!insideHasDelimiter && !beforeIsDelimiter && !afterIsDelimiter) return start to end
var lower = start
while (lower > 0 && text[lower - 1] in DELIMITER_CHARS) lower--
var upper = end
while (upper < text.length && text[upper] in DELIMITER_CHARS) upper++
return lower to upper
}
/** Removes unpaired (odd-count) delimiter runs, preserving properly paired `**`, `~~`, `` ` `` and `*`. */
private fun cleanOrphanedDelimiters(text: String): String {
var result = text
for (delimiter in listOf("**", "~~", "`", "*")) {
result = cleanOrphanedPairs(result, delimiter)
}
return result
}
private fun cleanOrphanedPairs(text: String, delimiter: String): String {
var count = 0
var index = text.indexOf(delimiter)
while (index >= 0) {
count++
index = text.indexOf(delimiter, index + delimiter.length)
}
if (count % 2 == 0) return text
val last = text.lastIndexOf(delimiter)
return if (last < 0) text else text.removeRange(last, last + delimiter.length)
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.format_bold
import org.meshtastic.core.resources.format_code
import org.meshtastic.core.resources.format_italic
import org.meshtastic.core.resources.format_link
import org.meshtastic.core.resources.format_strikethrough
import org.meshtastic.core.resources.insert
import org.meshtastic.core.resources.insert_link_message
import org.meshtastic.core.resources.insert_link_title
import org.meshtastic.core.resources.insert_link_url_hint
import org.meshtastic.core.ui.component.InlineStyle
import org.meshtastic.core.ui.icon.Bold
import org.meshtastic.core.ui.icon.Code
import org.meshtastic.core.ui.icon.Italic
import org.meshtastic.core.ui.icon.Link
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Strikethrough
import org.meshtastic.feature.messaging.FormattingResult
import org.meshtastic.feature.messaging.insertDelimiters
import org.meshtastic.feature.messaging.isMarkdownLink
import org.meshtastic.feature.messaging.unwrapLink
import org.meshtastic.feature.messaging.wrapSelection
import org.meshtastic.feature.messaging.wrapSelectionWithLink
/**
* A row of markdown formatting actions (bold, italic, strikethrough, code, link) that mutate [state] in place. A
* collapsed cursor inserts an empty delimiter pair; a non-empty selection is wrapped or, if already wrapped, toggled
* off. The link action opens a URL-entry dialog, or unwraps a selected `[label](url)` back to its label.
*/
@Composable
internal fun FormattingToolbar(state: TextFieldState, modifier: Modifier = Modifier) {
var showLinkDialog by remember { mutableStateOf(false) }
var pendingLinkRange by remember { mutableStateOf<TextRange?>(null) }
fun applyEdit(mutate: (String, TextRange) -> FormattingResult?) {
val text = state.text.toString()
val result = mutate(text, state.selection) ?: return
state.edit {
replace(0, length, result.text)
selection = result.selection
}
}
fun onStyle(style: InlineStyle) = applyEdit { text, selection ->
if (selection.collapsed) insertDelimiters(text, selection.min, style) else wrapSelection(text, selection, style)
}
fun onLink() {
val text = state.text.toString()
val selection = state.selection
val selected = text.substring(selection.min, selection.max)
if (!selection.collapsed && isMarkdownLink(selected)) {
applyEdit { _, _ -> unwrapLink(text, selection) }
} else {
pendingLinkRange = selection
showLinkDialog = true
}
}
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
FormatButton(MeshtasticIcons.Bold, stringResource(Res.string.format_bold)) { onStyle(InlineStyle.Bold) }
FormatButton(MeshtasticIcons.Italic, stringResource(Res.string.format_italic)) { onStyle(InlineStyle.Italic) }
FormatButton(MeshtasticIcons.Strikethrough, stringResource(Res.string.format_strikethrough)) {
onStyle(InlineStyle.Strikethrough)
}
FormatButton(MeshtasticIcons.Code, stringResource(Res.string.format_code)) { onStyle(InlineStyle.Code) }
FormatButton(MeshtasticIcons.Link, stringResource(Res.string.format_link)) { onLink() }
}
if (showLinkDialog) {
val range = pendingLinkRange
LinkDialog(
onDismiss = {
showLinkDialog = false
pendingLinkRange = null
},
onConfirm = { url ->
if (range != null) applyEdit { text, _ -> wrapSelectionWithLink(text, range, url) }
showLinkDialog = false
pendingLinkRange = null
},
)
}
}
@Composable
private fun FormatButton(icon: ImageVector, label: String, onClick: () -> Unit) {
IconButton(onClick = onClick) { Icon(imageVector = icon, contentDescription = label) }
}
@Composable
private fun LinkDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.insert_link_title)) },
text = {
OutlinedTextField(
value = url,
onValueChange = { url = it },
singleLine = true,
label = { Text(stringResource(Res.string.insert_link_message)) },
placeholder = { Text(stringResource(Res.string.insert_link_url_hint)) },
)
},
confirmButton = {
TextButton(onClick = { onConfirm(url) }, enabled = url.isNotBlank()) {
Text(stringResource(Res.string.insert))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}

View File

@@ -86,6 +86,54 @@ fun MessageItemSignedPreview() {
}
}
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun MessageItemMarkdownPreview() {
// Mixed inline markdown covering all five styles — bold, strikethrough, italic, code, and a link — as it arrives
// over the mesh (raw delimiters). The bubble should render styling with the delimiters removed (iOS parity).
val markdown =
Message(
text = "Meet at **noon** by the ~~old~~ new *bridge* — `README` and [map](https://example.com)",
time = "09:41",
fromLocal = false,
status = MessageStatus.RECEIVED,
snr = 7.0f,
rssi = 92,
hopsAway = 0,
uuid = 20L,
receivedTime = nowMillis,
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
packetId = 6001,
emojis = listOf(),
replyId = null,
viaMqtt = false,
)
AppTheme {
Column(
modifier =
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp),
) {
MessageItem(
message = markdown,
node = markdown.node,
selected = false,
ourNode = NodePreviewParameterProvider().mickeyMouse,
onReply = {},
sendReaction = {},
onShowReactions = {},
onClick = {},
onLongClick = {},
onDoubleClick = {},
onClickChip = {},
onNavigateToOriginalMessage = {},
)
}
}
}
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging
import androidx.compose.ui.text.TextRange
import org.meshtastic.core.ui.component.InlineStyle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Contract tests for the formatting helpers (see contracts/formatting-helpers.md, rows F-1…F-9). */
class MessageFormattingTest {
@Test // F-1
fun wrap_bold_wraps_selection_and_keeps_delimiters_selected() {
val result = wrapSelection("hello world", TextRange(6, 11), InlineStyle.Bold)
assertEquals("hello **world**", result.text)
assertEquals("**world**", result.text.substring(result.selection.min, result.selection.max))
}
@Test // F-2
fun wrap_bold_toggles_off_when_already_wrapped() {
// "hello **world**" — select the inner "world" (indices 8..13).
val result = wrapSelection("hello **world**", TextRange(8, 13), InlineStyle.Bold)
assertEquals("hello world", result.text)
assertEquals("world", result.text.substring(result.selection.min, result.selection.max))
}
@Test // F-3
fun collapsed_cursor_inserts_pair_with_caret_between() {
val result = wrapSelection("hi", TextRange(1), InlineStyle.Italic)
assertEquals("h**i", result.text)
assertTrue(result.selection.collapsed)
assertEquals(2, result.selection.start)
}
@Test // F-4
fun link_wraps_selection() {
val result = wrapSelectionWithLink("see docs", TextRange(4, 8), "https://e.com")
assertEquals("see [docs](https://e.com)", result.text)
}
@Test // F-5
fun link_inserts_placeholder_for_collapsed_cursor() {
val result = wrapSelectionWithLink("", TextRange(0), "https://e.com")
assertEquals("[link text](https://e.com)", result.text)
}
@Test // F-6
fun link_unwraps_to_label() {
val text = "[docs](https://e.com)"
val result = unwrapLink(text, TextRange(0, text.length))
assertEquals("docs", result?.text)
}
@Test // F-6 (negative)
fun unwrap_returns_null_for_non_link() {
assertNull(unwrapLink("plain text", TextRange(0, 10)))
}
@Test // F-7
fun wrapping_hugs_content_leaving_whitespace_outside() {
val result = wrapSelection(" world ", TextRange(0, 7), InlineStyle.Bold)
assertEquals(" **world** ", result.text)
}
@Test // F-8
fun wrapping_absorbs_adjacent_delimiter_without_orphan() {
// The stray '*' before the selection is absorbed rather than left orphaned.
val result = wrapSelection("a*b", TextRange(2, 3), InlineStyle.Bold)
assertEquals("a**b**", result.text)
}
@Test // F-9
fun contains_markdown_syntax_detects_and_rejects() {
assertTrue(containsMarkdownSyntax("a **b**"))
assertTrue(containsMarkdownSyntax("a *b*"))
assertTrue(containsMarkdownSyntax("a ~~b~~"))
assertTrue(containsMarkdownSyntax("a `b`"))
assertTrue(containsMarkdownSyntax("a [b](c)"))
assertFalse(containsMarkdownSyntax("a b"))
assertFalse(containsMarkdownSyntax(""))
}
@Test // isMarkdownLink
fun is_markdown_link_matches_exact_link_only() {
assertTrue(isMarkdownLink("[x](https://e.com)"))
assertFalse(isMarkdownLink("see [x](https://e.com) here"))
}
}

View File

@@ -87,6 +87,7 @@ dokka = "2.2.0"
devtools-ksp = "2.3.10"
firebase-crashlytics-gradle = "3.0.7"
google-services-gradle = "4.5.0"
markdown = "0.7.5"
markdownRenderer = "0.43.0"
okio = "3.17.0"
uri-kmp = "0.0.21"
@@ -265,6 +266,7 @@ dd-sdk-android-timber = { module = "com.datadoghq:dd-sdk-android-timber", versio
dd-sdk-android-trace = { module = "com.datadoghq:dd-sdk-android-trace", version.ref = "dd-sdk-android" }
dd-sdk-android-trace-otel = { module = "com.datadoghq:dd-sdk-android-trace-otel", version.ref = "dd-sdk-android" }
dokka-android-documentation-plugin = { module = "org.jetbrains.dokka:android-documentation-plugin", version.ref = "dokka" }
jetbrains-markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }

View File

@@ -22,6 +22,7 @@ import com.android.tools.screenshot.PreviewTest
import org.meshtastic.feature.messaging.EditQuickChatDialogPreview
import org.meshtastic.feature.messaging.MessageInputPreview
import org.meshtastic.feature.messaging.QuickChatItemPreview
import org.meshtastic.feature.messaging.component.MessageItemMarkdownPreview
import org.meshtastic.feature.messaging.component.MessageItemSignedPreview
import org.meshtastic.feature.messaging.component.MessageItemStatusStatesPreview
import org.meshtastic.feature.messaging.component.MessageSearchBarPreview
@@ -75,3 +76,10 @@ fun ScreenshotMessageItemSigned() {
fun ScreenshotMessageItemStatusStates() {
MessageItemStatusStatesPreview()
}
@PreviewTest
@PreviewLightDark
@Composable
fun ScreenshotMessageItemMarkdown() {
MessageItemMarkdownPreview()
}

View File

@@ -0,0 +1,46 @@
# Contract: AutoLinkText render extension
**Location**: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt`
**Change**: extend the existing private `buildAnnotatedStringWithLinks(...)`; the public `AutoLinkText(...)` composable signature is UNCHANGED (callers in `feature/messaging/MessageItem.kt` need no edits).
## Existing public surface (unchanged)
```kotlin
@Composable
fun AutoLinkText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
linkStyles: TextLinkStyles = DefaultTextLinkStyles,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
mentionName: ((String) -> String?)? = null,
onMentionClick: ((String) -> Unit)? = null,
)
```
## Behavioral contract for the extended builder
Processing order (research R3), producing ONE display string + spans in one coordinate system:
1. **Mention substitution** (existing): `@!<hex>``@DisplayName`; record display-space mention ranges.
2. **Markdown parse** (new): call `parseInlineMarkdown` on the mention-substituted text → `displayText`, `styleSpans`, `linkSpans`.
3. **Autolink** (existing): run URL/email/phone regex over `displayText`, skipping `usedIndices`.
4. **Build** `AnnotatedString(displayText)` applying, in order: mention `LinkAnnotation.Clickable`, markdown `StyleSpan`s (`SpanStyle`), markdown `LinkSpan`s (`LinkAnnotation.Url`, styled via `linkStyles`), autolink `LinkAnnotation.Url`. Mark each applied range in `usedIndices`.
| ID | Given | Then |
|----|-------|------|
| A-1 | `**bold**` | "bold" bold, no asterisks shown (FR-001) |
| A-2 | `[label](https://e.com)` | "label" tappable link, syntax removed (FR-002) |
| A-3 | `see https://e.com` (bare) | autolinked as today (FR-003) |
| A-4 | `[x](https://e.com) and https://e.com` | markdown link on `x`; bare URL also linked; no double-link inside the markdown link (FR-003) |
| A-5 | `**@!abcd1234**` | mention resolves to bold display name, correct offsets (FR-004) |
| A-6 | markdown parse fails/none | falls back to plain autolinked text (no regression) |
| A-8 | emoji-only text | markdown parsing short-circuited; renders unchanged (FR-006) |
| A-7 | `remember(text, linkStyles, mentionName)` cache key | unchanged caching behavior; parse runs inside the existing `remember` (NFR-004) |
## Invariants
- Public composable signature and all existing call sites remain source-compatible.
- No span range exceeds `displayText.indices`; overlapping ranges resolved via `usedIndices` (markdown links and mentions win over bare autolinks on conflict).
- Desktop and Android render identically (shared `commonMain`).

View File

@@ -0,0 +1,39 @@
# Contract: Formatting helpers (toolbar mutation logic)
**Location**: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageFormatting.kt`
**Kind**: pure functions, `commonTest`-covered. Mirror of iOS `MarkdownFormatting.swift`. No `android.*` or UI-framework imports; `TextRange` (`androidx.compose.ui.text.TextRange`) is a Compose **Multiplatform** `commonMain` value type and is allowed — it carries no Android/desktop platform dependency and is fully constructible in `commonTest`. (If a strictly framework-free surface is preferred, substitute a plain `IntRange`/`Int` pair, closer to iOS's `String.Index` ranges.)
**Consumed by**: `FormattingToolbar` via `TextFieldState.edit { }`.
## Signatures (illustrative)
```kotlin
fun wrapSelection(text: String, range: TextRange, style: InlineStyle): FormattingResult
fun insertDelimiters(text: String, at: Int, style: InlineStyle): FormattingResult
fun wrapSelectionWithLink(text: String, range: TextRange, url: String): FormattingResult
fun unwrapLink(text: String, range: TextRange): FormattingResult? // null if not a link
fun isMarkdownLink(text: String): Boolean
fun containsMarkdownSyntax(text: String): Boolean
```
`FormattingResult(text, selection)` — new raw text + new `TextRange` selection.
## Behavioral contract
| ID | Given | Then |
|----|-------|------|
| F-1 | selection `world` in `hello world`, Bold | `hello **world**`, selection covers `**world**` (FR-011/012) |
| F-2 | selection `**world**` already wrapped, Bold | toggles off → `hello world` (FR-012) |
| F-3 | collapsed cursor, Italic | inserts `**``*|*` with caret between (FR-012) |
| F-4 | selection `docs`, Link, url `https://e.com` | `[docs](https://e.com)` (FR-013) |
| F-5 | collapsed cursor, Link | inserts `[link text](url)` placeholder (FR-013) |
| F-6 | selection is `[docs](url)`, Link | unwraps to `docs` (FR-013) |
| F-7 | selection ` world ` (padded) | delimiters hug content: `**world**` with outer spaces preserved (FR-016) |
| F-8 | selection cuts through existing `*` | no orphaned unpaired delimiter left (FR-016) |
| F-9 | `containsMarkdownSyntax("a **b**")` | true; `containsMarkdownSyntax("a b")` false |
## Invariants
- Every returned `selection` is a valid `TextRange` within the returned `text`.
- Functions are total and deterministic; never throw on valid `(text, range)` pairs.
- Byte-limit is NOT enforced here — the caller (compose field) owns the 200-byte gate (FR-014); helpers only transform text/selection.
- No `android.*` or UI-framework imports; `TextRange` (Compose MP, `commonMain`) is permitted so they still run in `commonTest`.

View File

@@ -0,0 +1,42 @@
# Contract: Inline Markdown Parser
**Location**: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/InlineMarkdown.kt`
**Kind**: pure function, no Compose/Android deps, `commonTest`-covered.
**Backing library**: `org.jetbrains:markdown` 0.7.5 (AST).
## Signature (illustrative)
```kotlin
internal fun parseInlineMarkdown(source: String): InlineMarkdownResult
```
- `source` — the text to parse, AFTER mention substitution (display-space input; see AutoLinkText contract for ordering).
- Returns `InlineMarkdownResult(displayText, styleSpans, linkSpans)` (see data-model.md).
## Behavioral contract
| ID | Given | Then |
|----|-------|------|
| P-1 | `a **b** c` | displayText `a b c`; one `StyleSpan(Bold)` over `b` |
| P-2 | `a *b* c` | italic span over `b`; single `*` not treated as bold |
| P-3 | `a ***b*** c` | bold+italic spans both covering `b` (overlap allowed) |
| P-4 | `a ~~b~~ c` | strikethrough span over `b` |
| P-5 | `` a `b*c` d `` | code span over `b*c`; the `*` inside is NOT italic (code precedence) |
| P-6 | `[x](https://e.com)` | displayText `x`; `LinkSpan(range=x, url=https://e.com)` |
| P-7 | `**oops` (unpaired) | displayText contains literal `**oops`; no span; no throw (FR-005) |
| P-8 | `# heading\n- item` (block syntax) | rendered literally as text; no heading/list structure (FR-008) |
| P-9 | `line1\nline2` | newline preserved in displayText (FR-007) |
| P-10 | empty / blank | returns empty displayText, empty span lists |
| P-11 | emoji-only (caller guards) | caller skips parser; parser itself still safe if called |
## Invariants
- `displayText` never contains a delimiter that was consumed to produce a span.
- Every `StyleSpan.range` / `LinkSpan.range` is within `displayText.indices`.
- Deterministic: same input → same output (no time/randomness).
- Total: never throws on any `String` input; malformed markdown degrades to literal text.
## Notes
- Strikethrough (`~~`) requires a GFM-capable flavour or a minimal pre-pass (research R2); either satisfies P-4.
- The parser does not resolve mentions or bare URLs/emails/phones — those are layered by `AutoLinkText` (separation of concerns).

View File

@@ -0,0 +1,25 @@
# Contract: Live in-field styling (OutputTransformation)
**Location**: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt`
**Change**: add a markdown `OutputTransformation` and compose it with the existing `mentionOutputTransformation` on the compose field's `TextFieldState`.
**API**: Compose Multiplatform 1.11.1 `androidx.compose.foundation.text.input.OutputTransformation` / `TextFieldBuffer.addStyle`.
## Behavioral contract
| ID | Given | Then |
|----|-------|------|
| O-1 | draft `see **this**` | field displays "this" bold live while typing (FR-018) |
| O-2 | draft with a mention + markdown | both the `@Name` substitution and markdown styling render together, no offset conflict (research R5 risk) |
| O-3 | any styling applied | `TextFieldState.text` (the stored/raw value) is UNCHANGED — bytes sent are identical (FR-018 / NFR-001) |
| O-4 | draft with no markdown | field renders plain (no spurious styling) |
| O-5 | send / byte-truncation / quick-chat programmatic edits | no crash from stale selection/offset (guard as the existing code already does when clearing text) |
## Invariants
- Transformation is display-only: it MUST NOT mutate `TextFieldState.text`. Styling is applied via `TextFieldBuffer.addStyle` on the displayed buffer only.
- Must compose with (not replace) the mention transformation — either a single combined transformation or a well-defined chain; the combined result must keep offsets valid.
- Works on both Android and desktop (shared `commonMain`).
## Verification note
Because `OutputTransformation` composition is the one novel integration risk (research R5/R6 open risk), it MUST be exercised live per quickstart — not only via tests — driving: type markdown, type a mention, mix both, send, quick-chat append, and byte-limit truncation, watching for correct styling and no offset crash.

View File

@@ -0,0 +1,55 @@
# Phase 1 Data Model: Message Inline Markdown Styling
**No persisted entities, no database migration, no protobuf changes.** Markdown is parsed at render time and styled at display time; nothing is stored. This document defines only the **in-memory** types the parser and renderer pass around.
## Coordinate systems
- **Raw text** — the exact bytes the user typed / that arrived on the mesh, delimiters included (`"a **b** c"`). This is what `TextFieldState.text` holds and what `sendMessage` transmits.
- **Display text** — delimiters stripped, mentions substituted to display names (`"a b c"`). All spans are expressed in display-space offsets.
- The parser is responsible for producing the display string **and** the spans in one pass so offsets are internally consistent (research R3).
## Types (in-memory, `commonMain`, no Compose dependency)
### `InlineStyle` (enum)
The five inline styles, parity with iOS `MarkdownStyle`.
| Value | Delimiter(s) | Rendered as |
|-------|--------------|-------------|
| `Bold` | `**` | `FontWeight.Bold` |
| `Italic` | `*` | `FontStyle.Italic` |
| `Strikethrough` | `~~` | `TextDecoration.LineThrough` |
| `Code` | `` ` `` | `FontFamily.Monospace` |
| `Link` | `[text](url)` | `LinkAnnotation.Url` (styled + tappable) |
### `StyleSpan`
An inline style applied over a display-space range.
- `range: IntRange` — half-open in practice (`start until end`), display-space.
- `style: InlineStyle` — one of the non-link styles (`Bold`/`Italic`/`Strikethrough`/`Code`). Multiple `StyleSpan`s may overlap (e.g. bold+italic).
### `LinkSpan`
A markdown link over a display-space range.
- `range: IntRange` — covers the link's display text (the label), display-space.
- `url: String` — the resolved destination.
### `InlineMarkdownResult`
The parser's return value — the single source of truth for a render.
- `displayText: String` — delimiter-stripped text (mentions already substituted by the caller before parsing, per R3).
- `styleSpans: List<StyleSpan>`
- `linkSpans: List<LinkSpan>`
> The renderer (`AutoLinkText`) consumes `InlineMarkdownResult`, then layers mention `LinkAnnotation.Clickable` spans and URL/email/phone autolink spans over the same `displayText`, using `usedIndices` to prevent overlap with markdown links.
## Authoring types (transient, `feature/messaging`)
### `FormattingResult`
Return of each pure formatting helper (parity with iOS `FormattingResult`).
- `text: String` — new raw text after the edit.
- `selection: TextRange` — new selection to apply to the `TextFieldState`. `TextRange` is a Compose **Multiplatform** `commonMain` value type (no Android/desktop platform dependency); the helpers therefore stay `commonTest`-runnable. A plain `IntRange`/`Int` pair is an acceptable framework-free substitute if preferred.
No other state is introduced. Toolbar visibility (`focus && text.length >= 3`), the link-dialog URL string, and the pending link range are ordinary composable `remember`/state, not model entities.
## What is explicitly NOT added
- No new column on the message entity, no `messagePayloadMarkdown`-style stored/derived field (Android parses at render time, unlike iOS which persists a derived autolinked payload). Confirmed against `feature/messaging` + `core/database`.
- No protobuf field; wire payload = raw `TextFieldState.text`.
- No new Room migration.

View File

@@ -0,0 +1,107 @@
# Implementation Plan: Message Inline Markdown Styling (iOS Parity)
**Branch**: `claude/apple-markdown-styling-parity-be3a63` (proposed rename: `20260711-153545-message-markdown-styling`) | **Date**: 2026-07-11 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/20260711-153545-message-markdown-styling/spec.md`
## Summary
Render and author lightweight **inline** markdown (bold, italic, strikethrough, inline code, links) in mesh text messages, matching iOS PR #1771. Rendering extends the shared `core/ui` `AutoLinkText` `AnnotatedString` builder with an `org.jetbrains:markdown` (intellij-markdown) AST walk that emits inline spans, interleaved with the existing mention + URL/email/phone spans. Authoring adds a Compose formatting toolbar over the existing `TextFieldState`-based compose field plus live in-field styling via `OutputTransformation` (the same mechanism already rendering `@mentions`). The mesh wire payload is unchanged — Android sends the raw delimiters exactly as iOS does. All logic lives in `commonMain`, so Android and desktop share one implementation.
## Technical Context
**Language/Version**: Kotlin 2.4.0 (JDK 25 toolchain; bytecode target JVM 21)
**Primary Dependencies**: Compose Multiplatform 1.11.1 (`foundation` provides `TextFieldState`/`OutputTransformation`), Compose Multiplatform Material3 1.11.0-alpha07, **new:** `org.jetbrains:markdown` 0.7.5 (matches the version already resolved transitively via the block `multiplatform-markdown-renderer` 0.43.0 — pinning to it avoids a catalog version conflict)
**Storage**: N/A — no database or proto changes. Markdown is parsed at render time; nothing persisted. The wire payload equals `TextFieldState.text` (raw delimiters).
**Testing**: `commonTest` (kotlin-test) for the pure parser + formatting helpers; existing Robolectric/androidHostTest harness for any Compose-touching assertions; Compose screenshot tests for `MessageItem`/`MessageInput` previews via `updateDebugScreenshotTest`.
**Target Platform**: Android (all supported API levels — no OS gate, unlike iOS's iOS-18 floor) + JVM desktop (shared `commonMain`).
**Project Type**: KMP mobile + desktop app; feature-module architecture.
**Performance Goals**: Annotated-string construction stays inside the existing `remember(text, …)` cache in `AutoLinkText`; parsing is a single AST pass per message render, not per frame. No regression to message-list scroll/paging.
**Constraints**: 200-byte message limit enforced on the raw text (delimiters count, as they are on the wire); no `java.*`/`android.*` in `commonMain`; zero detekt/spotless findings.
**Scale/Scope**: Two shared components extended/added in `core/ui`; toolbar + wiring in `feature/messaging`; 4 new drawable icons + strings in `core/resources`; one new catalog entry. ~5 code units, all `commonMain`.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **I. Kotlin Multiplatform Core** — ✅ All logic in `commonMain`. Touched source sets: `core/ui/commonMain` (renderer + parser), `feature/messaging/commonMain` (toolbar, in-field styling, field wiring), `core/resources/commonMain` (icons + strings), `commonTest` (parser + helper tests). No `androidMain`/`jvmMain` code required — `TextFieldState`, `OutputTransformation`, `AnnotatedString`, and `org.jetbrains:markdown` are all multiplatform.
- **II. Zero Lint Tolerance** — ✅ Will run `./gradlew spotlessApply spotlessCheck detekt` for `:core:ui`, `:feature:messaging`, `:core:resources`. Watch: detekt `MatchingDeclarationName` (put any enum/data classes after functions in new files); no hardcoded user-facing strings.
- **III. Compose Multiplatform UI** — ✅ Toolbar uses M3 components + `MeshtasticIcons`; no float formatting involved; no navigation changes (in-screen only, so `MeshtasticNavDisplay`/`NavigationBackHandler` N/A). Live styling uses the CMP `OutputTransformation` API already in the module.
- **IV. Privacy First** — ✅ No logging of message content; no PII/location/keys; no proto edits (wire format unchanged, no new fields). Markdown is presentation-only.
- **V. Design Standards Compliance** — ⚠️ New user-facing UI (formatting toolbar). Must check the toolbar against `.skills/design-standards` and reference upstream iOS behavior (PR #1771) as the cross-platform source. New format icons must match the MeshtasticIcons visual language (Material Symbols line weight). Recorded in Phase 1 quickstart checklist.
- **VI. Documentation Freshness** — ⚠️ User-facing behavior change (message styling + toolbar). Either update the relevant `docs/` messaging page (with `last_updated` frontmatter) or apply `skip-docs-check` with justification. Flagged as a task.
- **VII. Verify Before Push** — ✅ Local: `./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests kmpSmokeCompile` + `python3 scripts/sort-strings.py` after adding strings. Post-push: `gh pr checks`. Delegate heavy Gradle to the `gradle-runner` subagent; git-diff-verify after (it can mutate the tree).
No unjustifiable violations. The two ⚠️ items (design review, docs) are process obligations, tracked as tasks, not architectural exceptions — Complexity Tracking is empty.
## Project Structure
### Documentation (this feature)
```text
specs/20260711-153545-message-markdown-styling/
├── spec.md # Feature specification (+ Clarifications)
├── plan.md # This file
├── research.md # Phase 0 output — decisions & rationale
├── data-model.md # Phase 1 output — in-memory span/parse model (no DB)
├── quickstart.md # Phase 1 output — build/verify/design-check steps
├── contracts/ # Phase 1 output — function/API contracts
│ ├── inline-markdown-parser.md
│ ├── autolinktext-render.md
│ ├── formatting-helpers.md
│ └── output-transformation.md
└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here)
```
### Source Code (repository root)
```text
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/
├── AutoLinkText.kt # MODIFY: interleave markdown spans into buildAnnotatedStringWithLinks
└── InlineMarkdown.kt # NEW: pure parser — raw String -> (display, styleSpans, linkSpans)
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/
└── Actions.kt (or new Formatting.kt) # NEW MeshtasticIcons extension vals: Bold, Italic, Strikethrough, Code (Link exists)
core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/
└── InlineMarkdownTest.kt # NEW: parser + render-corpus tests
core/resources/src/commonMain/composeResources/
├── drawable/ic_format_bold.xml # NEW
├── drawable/ic_format_italic.xml # NEW
├── drawable/ic_format_strikethrough.xml # NEW
├── drawable/ic_code.xml # NEW (ic_link.xml already exists)
└── values/strings.xml # MODIFY: toolbar a11y labels + link dialog strings
feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/
├── Message.kt # MODIFY: host toolbar; add markdown OutputTransformation (compose with mention one)
└── component/FormattingToolbar.kt # NEW: toolbar composable + link dialog
feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/
└── MessageFormatting.kt # NEW: pure wrap/toggle/insert/link/orphan-clean helpers (mirror iOS MarkdownFormatting.swift)
feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/
└── MessageFormattingTest.kt # NEW: helper unit tests
gradle/libs.versions.toml # MODIFY: add `markdown = "0.7.5"` version + `jetbrains-markdown` library
core/ui/build.gradle.kts # MODIFY: add libs.jetbrains.markdown to commonMain
```
**Structure Decision**: Rendering logic goes in `core/ui` (shared, already home to `AutoLinkText`) so desktop inherits it; authoring UI + text-mutation logic goes in `feature/messaging` (owns the compose field and its `TextFieldState`). Pure functions (`InlineMarkdown`, `MessageFormatting`) carry no `android.*`/UI-framework dependency so they unit-test in `commonTest` without a UI harness — mirroring iOS's testable `MarkdownFormatting.swift`. (`MessageFormatting` uses the Compose-Multiplatform `commonMain` value type `TextRange`, which is platform-free and `commonTest`-constructible.)
## Complexity Tracking
> No constitution violations requiring justification. Section intentionally empty.
## Phase Summary
- **Phase 0 — Research** (`research.md`): parser choice & version pin, inline-only AST strategy, offset-ordering with mentions/autolinks, `OutputTransformation` styling approach, icon/font sourcing. All prior NEEDS CLARIFICATION resolved (see Clarifications in spec + research.md).
- **Phase 1 — Design & Contracts** (`data-model.md`, `contracts/`, `quickstart.md`): the in-memory parse model (no DB), the four function/API contracts, and the build/verify/design-check runbook.
- **Phase 2 — Tasks**: produced by `/speckit.tasks` (dependency-ordered, P1→P2→P3 build order within one delivery). NOT created by this command.

View File

@@ -0,0 +1,36 @@
# Message inline markdown styling (iOS parity)
## Why
Because iOS transmits **raw markdown delimiters over the mesh** (styling is presentation-only — there is no separate formatted payload), every Android user in a channel with an iOS 18+ user *already receives* messages full of literal `**asterisks**` and `~~tildes~~` today, and renders them as garbled literal text. Closing the **render** gap is the higher-value, lower-risk half of parity; the **authoring** half (a formatting toolbar + live in-field styling) matches the iOS compose editor.
Cross-platform reference: meshtastic-apple PR [#1771](https://github.com/meshtastic/meshtastic-apple/pull/1771).
## What
Inline markdown — **bold**, *italic*, ~~strikethrough~~, `inline code`, and `[links](url)` — rendered in bubbles and authorable from the compose field. All logic lives in shared `commonMain`, so Android **and** desktop get one implementation. Delivered as three internally-staged user stories (single PR):
### 🌟 New
- **Render (US1):** message bubbles now render the five inline styles with delimiters removed; existing autolinking, `@`-mention resolution, and reply/search behavior are preserved. Parse failures fall back to plain autolinked text; emoji-only messages short-circuit parsing unchanged.
- **Toolbar (US2):** a 5-button formatting row (bold/italic/strikethrough/code/link) over the compose field — shown on focus with ≥3 characters. Selection wrap/toggle, collapsed-cursor insert, and a link-entry dialog (wrap / unwrap) mirror the iOS `MarkdownFormatting` rules byte-for-byte.
- **Live in-field styling (US3):** the draft styles live in the field via a markdown `OutputTransformation` composed with the existing mention transformation — display-only, never mutating the stored `TextFieldState.text`.
### 🛠️ Changes
- New direct dependency `org.jetbrains:markdown` (0.7.5) in `core/ui` — pins the version already resolved transitively via `multiplatform-markdown-renderer` 0.43.0, so no new resolved artifact.
- `AutoLinkText.buildAnnotatedStringWithLinks` extended to interleave mention → markdown-style → markdown-link → bare-autolink spans in one display-space coordinate system.
- Four new `ic_format_*`/`ic_code` drawables on the house 960-grid Material Symbols convention (matching the neighboring `ic_link`).
## Notes / scope boundaries
- **Wire format unchanged** — Android continues to send exactly what the user typed (raw delimiters), matching iOS. **No protobuf and no database changes.**
- **Search divergence (intentional):** while a message search is active, bubbles render plain text + query highlight only (no markdown), per the clarified spec.
- Block-level markdown (headers, lists, tables, code fences) is explicitly out of scope — iOS is inline-only; the catalog's `multiplatform-markdown-renderer` is a *block* renderer and the wrong tool for chat bubbles.
- Channel-URL tap interception is deferred to a separate story.
## Testing Performed
- `:core:ui` `commonTest``InlineMarkdownTest` (parser contract, P-1…P-11) and `AutoLinkTextRenderTest` (render contract, A-1…A-8).
- `:feature:messaging` `commonTest``MessageFormattingTest` (formatting-helper contract F-1…F-9, including iOS parity fixtures for byte-identical output).
- Baseline: `spotlessApply spotlessCheck detekt assembleDebug kmpSmokeCompile` — green.
- **Live on-device verify** (fdroid-debug on an emulator + meshcon replay):
- **US1 render** — injected `Meet at **noon** by the ~~old~~ new *bridge* — \`README\` and [map](https://example.com)`; the bubble rendered noon=bold, old=strikethrough, bridge=italic, README=monospace, map=blue tappable link, with **all delimiters stripped**.
- **US2 toolbar** — appears on focus (≥3 chars) with all five buttons; tapping Bold on a selection wrapped it to `**testa**` with the selection growing to cover the delimiters (iOS behavior).
- **US3 live styling** — typing `hello**world**` styled "world" bold live in the field while the raw `**` stayed visible and the byte counter tracked the raw text (transform is display-only).

View File

@@ -0,0 +1,63 @@
# Quickstart: Message Inline Markdown Styling
Build/verify/design-check runbook for implementers and reviewers.
## Bootstrap (once per shell)
```bash
[ -z "$ANDROID_HOME" ] && export ANDROID_HOME="$HOME/Library/Android/sdk"
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## Dependency wiring
1. `gradle/libs.versions.toml`:
- `[versions]` add `markdown = "0.7.5"`
- `[libraries]` add `jetbrains-markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }`
2. `core/ui/build.gradle.kts``commonMain.dependencies { implementation(libs.jetbrains.markdown) }`
## Build order within the single delivery (P1 → P2 → P3)
- **P1 render**: `InlineMarkdown.kt` parser + `AutoLinkText` extension + `InlineMarkdownTest`. Verify received/injected markdown renders styled.
- **P2 toolbar**: `MessageFormatting.kt` helpers + `MessageFormattingTest` + `FormattingToolbar.kt` + `Message.kt` wiring + 4 icons + strings.
- **P3 in-field styling**: markdown `OutputTransformation` composed with the mention one in `Message.kt`.
## Strings & icons
- Add toolbar a11y labels + link-dialog strings to `core/resources/src/commonMain/composeResources/values/strings.xml` (via `stringResource(Res.string.*)`; no hardcoded UI text).
- Run `python3 scripts/sort-strings.py` after adding strings.
- Add drawables `ic_format_bold`, `ic_format_italic`, `ic_format_strikethrough`, `ic_code` (Material Symbols line style, matching existing `ic_*`); `ic_link` already exists. Add `MeshtasticIcons.{Bold,Italic,Strikethrough,Code}` extension vals.
## Verification
Delegate heavy Gradle to the `gradle-runner` subagent; **git-diff-verify after** (it can mutate the tree). Baseline:
```bash
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests kmpSmokeCompile
```
Single-module fast loops:
```bash
./gradlew :core:ui:allTests --tests "*InlineMarkdown*"
./gradlew :feature:messaging:allTests --tests "*MessageFormatting*"
```
### Live verification (mandatory — /verify skill)
Drive the real flow, not just tests:
- Receive/inject a mixed message: `Meet at **noon** by the ~~old~~ new *bridge* — \`README\` and [map](https://example.com)`; confirm all five styles + tappable link, no delimiters, mention + bare-URL still work.
- Search a conversation → confirm markdown is suppressed and highlight still works (FR-009).
- Compose: select text → each toolbar button wraps/toggles; collapsed cursor inserts a pair; Link dialog wraps/unwraps; live in-field styling shows as you type (O-1..O-5).
- Stress the `OutputTransformation` composition: markdown + mention together, then send, quick-chat append, and type past 200 bytes — watch for offset crashes.
- Desktop: confirm the same rendering (shared `commonMain`).
## Design & docs gates (Constitution V/VI)
- [ ] Toolbar checked against `.skills/design-standards`; new icons match MeshtasticIcons weight; touch targets ≥ 44dp; TalkBack labels present.
- [ ] Reference iOS PR #1771 as the cross-platform behavior source.
- [ ] Update the messaging `docs/` page (`last_updated` frontmatter) OR apply `skip-docs-check` with justification.
## Screenshot tests
Regenerate Compose preview screenshots for `MessageItem`/`MessageInput` via `updateDebugScreenshotTest`; note `allTests` may dirty tracked `docs/assets/screenshots/*.png` on this Mac — `git checkout --` unintended changes before committing.

View File

@@ -0,0 +1,60 @@
# Phase 0 Research: Message Inline Markdown Styling
All decisions below resolve the spec's Clarifications and the plan's Technical Context. No NEEDS CLARIFICATION remain.
## R1 — Markdown parser
- **Decision**: Use `org.jetbrains:markdown` (intellij-markdown), pinned to **0.7.5**, added as a direct `commonMain` dependency of `core/ui` and a version-catalog entry.
- **Rationale**: Pure-Kotlin multiplatform CommonMark parser producing an AST whose nodes carry source ranges — those ranges are exactly the raw→display offset map the render pipeline needs. A real parser gets nesting, `**`-vs-`*` precedence, and "no re-interpretation inside code spans" correct for free (spec FR-005, Edge Cases). **0.7.5 is the version already resolved transitively** by the catalog's `multiplatform-markdown-renderer` 0.43.0, so pinning to it avoids introducing a second, conflicting version.
- **Alternatives considered**:
- *Hand-rolled inline tokenizer* — dependency-free and tiny, but re-derives every CommonMark edge case and risks diverging from the iOS-authored corpus. Recorded as the fallback if the dependency is ever rejected.
- *`multiplatform-markdown-renderer` block Composable* — wrong altitude for chat bubbles (can't interleave mention/link spans, brings block layout). Rejected in spec.
- *Android `HtmlCompat.fromHtml` / `Linkify` / `AnnotatedString.fromHtml`* — Android-only + HTML/autolink-only; break the `commonMain`/desktop goal. Rejected.
## R2 — Inline-only rendering from a block-capable AST
- **Decision**: Parse with the CommonMark flavour, then walk the AST emitting spans **only** for inline node types (`STRONG`→bold, `EMPH`→italic, `CODE_SPAN`→monospace, `INLINE_LINK`/link destination→`LinkAnnotation.Url`, plus GFM `~~`strikethrough if the flavour exposes it; otherwise handle `~~` as a small pre-pass). Block structure (paragraphs, and any heading/list/quote syntax) is flattened to literal text with whitespace/newlines preserved (spec FR-007, FR-008).
- **Rationale**: Matches iOS's `.inlineOnlyPreservingWhitespace`. Walking the tree and appending display text as we go naturally produces the stripped display string plus a correct offset map, satisfying the pipeline's core correctness requirement.
- **Note**: CommonMark flavour does not include `~~`strikethrough; GFM flavour does. Decision: use the flavour that yields strikethrough, or add a minimal `~~…~~` span pass over the display text if staying on CommonMark. Confirmed as an implementation detail for the parser task, not a blocker.
- **Alternatives considered**: Emitting block spacing/structure — rejected (not parity; disrupts bubble layout).
## R3 — Offset ordering with mentions + autolinks
- **Decision**: Extend `buildAnnotatedStringWithLinks` to run, in order: (a) existing mention-token → display-name substitution (records display-space mention ranges), (b) **markdown parse + delimiter strip** over the mention-substituted text (records inline style ranges and markdown-link ranges, produces the final display string), (c) existing URL/email/phone autolink regex over the stripped display text, skipping `usedIndices`. Then build the `AnnotatedString` applying mention links, markdown style spans, markdown link spans, and autolink spans — all in the single display-space coordinate system.
- **Rationale**: There must be one consistent display string with one accurate index map so every span lands correctly (spec Rendering pipeline section, FR-003/FR-004). Running markdown between mention substitution and autolinking keeps autolink offsets valid after delimiter removal and prevents double-linking a URL already expressed as a markdown link.
- **Alternatives considered**: Layering independent regex replacements — rejected; compounding offset shifts is the documented source of span misplacement.
## R4 — Search-mode behavior
- **Decision**: When a search query is active, `MessageItem` continues to use `HighlightedText` (plain text + highlight) and does **not** apply markdown styling (spec FR-009).
- **Rationale**: Avoids reconciling delimiter-stripping offsets against highlight ranges; keeps the existing search path untouched. Accepted, documented divergence from iOS.
- **Alternatives considered**: Combined markdown + highlight — deferred; higher offset-reconciliation cost for marginal benefit while searching.
## R5 — Authoring: live in-field styling
- **Decision**: Style the draft live via Compose `OutputTransformation` on the existing `TextFieldState`, composed with the module's existing `mentionOutputTransformation`. No separate preview bubble (spec FR-018).
- **Rationale**: The API is already present in Compose Multiplatform 1.11.1 and already used in `Message.kt` for mentions; `TextFieldBuffer.addStyle` restyles the displayed buffer without mutating stored text (so the bytes sent are unchanged), and the new API removes the legacy visual-transformation offset-mapping crash class. Works on desktop.
- **Alternatives considered**: iOS-style separate preview bubble — superseded (redundant once the field itself styles); build only if in-field styling proves insufficient.
## R6 — Toolbar mutation logic
- **Decision**: Pure functions in `feature/messaging/MessageFormatting.kt` operating on `(String, TextRange) -> (String, TextRange)` for wrap/toggle, collapsed-cursor insert, link wrap/unwrap, whitespace-hug trimming, and orphaned-delimiter cleanup — mirroring iOS `MarkdownFormatting.swift`. The toolbar applies them through `TextFieldState.edit { }`. Unit-tested in `commonTest` (spec FR-017).
- **Rationale**: Isolating mutation logic from Compose gives high-value, harness-free tests and 1:1 traceability to the iOS reference behavior.
- **Alternatives considered**: Mutating inside composables — rejected (untestable without a UI harness).
## R7 — Icons and monospace font
- **Decision**: `FontFamily.Monospace` (Compose built-in, already used in `feature/settings` Debug/Logcat) for inline `code`. Link icon reuses the existing `ic_link` drawable; add four new drawables — `ic_format_bold`, `ic_format_italic`, `ic_format_strikethrough`, `ic_code` — to `core/resources` plus `MeshtasticIcons.{Bold,Italic,Strikethrough,Code}` extension vals (following the `vectorResource(Res.drawable.ic_*)` pattern in `icon/Actions.kt`).
- **Rationale**: No `material-icons-extended` dependency exists; the project sources icons as bundled Material Symbols XML drawables. Reusing that path keeps visual consistency (Constitution V) and avoids a new dependency.
- **Alternatives considered**: Adding `material-icons-extended` — rejected (heavier dependency; breaks the established bundled-drawable convention).
## R8 — Byte limit & wire format
- **Decision**: No change. The 200-byte limit already counts the raw text including delimiters (they travel on the wire); `sendMessage` continues to send `TextFieldState.text` verbatim. In-field styling and toolbar wrapping mutate only the raw text the user would have typed anyway.
- **Rationale**: Parity — iOS transmits raw delimiters; Android already does and must continue (spec NFR-001). No proto/DB fields (Constitution IV).
## Open risks (carried to tasks, not blockers)
- **Strikethrough on CommonMark flavour** (R2 note) — resolve during the parser task (GFM flavour vs. `~~` pre-pass).
- **`OutputTransformation` composition** — verify markdown styling and mention rendering compose cleanly in one transformation (or chain) without offset conflicts; covered by a manual verify step in quickstart.
- **Design review + docs** (Constitution V/VI) — process tasks.

View File

@@ -0,0 +1,281 @@
# Feature Specification: Message Inline Markdown Styling (iOS Parity)
**Feature Branch**: `claude/message-markdown-styling-parity` (proposed)
**Created**: 2026-07-11
**Status**: Draft
**Input**: "Investigate the iOS message-input markdown styling feature and develop a parity spec for Android."
**Cross-Platform Reference**: meshtastic-apple PR [#1771 — Add message formatting toolbar (iOS 18+)](https://github.com/meshtastic/meshtastic-apple/pull/1771)
## Clarifications
### Session 2026-07-11
- Q: What scope should the first delivery cover? → A: All three — P1 render, P2 authoring toolbar, and P3 draft styling — in one effort (staged internally P1→P2→P3, but not split across PRs).
- Q: Which markdown parsing approach for the renderer? → A: JetBrains `org.jetbrains:markdown` (intellij-markdown) AST walk; add as a direct version-catalog + `core/ui` dependency.
- Q: How should the draft preview / live styling (P3) work? → A: Live in-field styling via Compose `OutputTransformation` (as the existing `mentionOutputTransformation` does); no separate preview bubble unless in-field styling proves insufficient.
- Q: How should markdown behave during active message search? → A: Suppress markdown styling while a search query is active — render plain text + query highlight only.
- Q: Should channel-URL (`meshtastic.org/e/#…`) links tapped inside a bubble be intercepted (as iOS does)? → A: No — out of scope; deferred to a separate story that also covers existing autolinks (default retained).
## Summary
iOS renders and lets users author lightweight **inline markdown** in mesh text messages — **bold**, *italic*, ~~strikethrough~~, `inline code`, and `[links](url)` — plus automatic linkification of URLs, email addresses, phone numbers, and postal addresses. Android today renders none of the styling: it auto-links URLs/email/phone (via `AutoLinkText`) but shows the raw delimiter characters (`**bold**`, `~~strike~~`) as literal text.
**Because iOS transmits the raw markdown delimiters over the mesh**, every Android user in a channel with an iOS 18+ user *already receives* messages full of literal asterisks and tildes today. Closing the **render** gap is therefore the higher-value, lower-risk half of parity. The **authoring** half (a formatting toolbar + live in-field styling over the compose field) matches iOS's editor.
This spec covers all three prioritized user stories — **P1 render, P2 authoring toolbar, P3 draft styling** — as a single delivery (per Clarifications). Priorities denote build/verification order within that effort, not separate PRs.
## How iOS Works (reference behavior)
Two independent halves:
**Rendering (all iOS versions).** Message bubbles render `AttributedString(markdown:options:.init(interpretedSyntax: .inlineOnlyPreservingWhitespace))` — inline styling only (no headers, lists, blockquotes, images), whitespace/newlines preserved. Parse failure falls back to plain text. Links are underlined and tinted. A derived, persisted field (`messagePayloadMarkdown`) additionally auto-linkifies detected URLs / `tel:` / `mailto:` / `maps.apple.com?address=` via `NSDataDetector`, skipping matches already inside an existing `[…](…)` link. Emoji-only messages skip markdown entirely.
**Authoring (iOS 18+ / macOS 15+ only — gated on the new selection-observing `TextEditor`).**
- A 5-button toolbar over the compose field: bold `**`, italic `*`, strikethrough `~~`, code `` ` ``, link `[](url)`. Shown when the field is focused and holds ≥3 characters; buttons disabled when there is no text selection.
- Toggle semantics: wrapping a selection that is *already* wrapped removes the delimiters; a collapsed cursor inserts an empty delimiter pair and places the caret between them. Wrapping trims whitespace so delimiters hug content, absorbs adjacent delimiter characters the selection cuts through, and cleans orphaned unpaired delimiters.
- Link button opens a URL-entry alert and wraps the selection as `[selected](url)`; re-invoking on an existing link unwraps it to plain display text.
- A **live preview** bubble renders above the input showing the styled result, but only when the draft contains recognizable markdown syntax.
- **The message put on the wire is the raw draft text with literal delimiters** — styling is presentation-only; there is no separate "formatted" payload transmitted.
## Goals
1. **Read parity (P1):** Android renders inline markdown (bold, italic, strikethrough, inline code, links) in message bubbles so messages authored on iOS — which arrive with literal delimiters — display as intended.
2. **Write parity (P2):** Android offers an equivalent formatting toolbar over the compose field so Android users can author the same styling.
3. **Preview parity (P3):** Android shows a live styled preview of the draft, matching the iOS compose experience.
4. Preserve every existing messaging behavior: autolinking, `@`-mention resolution, search highlighting, reply snippets, byte-limit enforcement, reactions.
5. Keep the rendering logic in shared `commonMain` so Android **and** desktop (and any future iOS-KMP) benefit from one implementation.
## Non-Goals
- **Block-level markdown** (headers, lists, blockquotes, tables, images, code fences). iOS is inline-only; Android matches that. The `com.mikepenz:multiplatform-markdown-renderer` already in the catalog is a *block* renderer used by docs/firmware/settings and is the **wrong tool** here — it does not compose with per-span links/mentions/selection inside a chat bubble.
- Changing the **wire format**. Android continues to send exactly what the user typed (raw delimiters), matching iOS. No new protobuf fields, no `messagePayloadMarkdown`-style transmitted payload.
- A stored/derived markdown column in the database. Autolinking already happens at render time in `AutoLinkText`; markdown parsing joins it there rather than being precomputed and persisted.
- Rich-text WYSIWYG editing (the compose field still shows raw delimiters as you type, exactly like iOS). The toolbar only inserts/removes delimiter characters.
- Reconciling the "non-markdown clients see literal `**`" tradeoff — this is inherent to matching iOS and to the firmware/other-client ecosystem, and is out of scope to change unilaterally.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Render inline markdown in received & sent messages (Priority: P1)
As an Android user in a channel with iOS users, I see **bold**, *italic*, ~~strikethrough~~, `code`, and links rendered with styling instead of raw `**`/`*`/`~~`/`` ` ``/`[](…)` characters.
**Why this priority**: Highest value, lowest risk, independently shippable. iOS 18+ users are *already* emitting markdown onto the mesh; this is a pure display fix in shared code with no wire/DB/compose changes. Delivers cross-client parity by itself.
**Independent Test**: Inject (or receive) a message `Meet at **noon** by the ~~old~~ new *bridge* — see \`README\` and [map](https://example.com)`. The bubble renders bold "noon", struck "old", italic "bridge", monospaced "README", and a tappable "map" link; no literal delimiter characters remain.
**Acceptance Scenarios**:
1. **Given** a message `**bold**`, **When** rendered in a bubble, **Then** "bold" is FontWeight.Bold and the asterisks are not shown.
2. **Given** `*italic*`, **Then** "italic" is italicized; **Given** `~~strike~~`, **Then** "strike" has line-through; **Given** `` `code` ``, **Then** "code" uses a monospace family.
3. **Given** `[label](https://example.com)`, **Then** "label" renders as a styled, tappable link to `https://example.com` and the bracket/paren syntax is not shown.
4. **Given** a bare URL, email, or phone number (no markdown syntax), **Then** existing autolink behavior is unchanged.
5. **Given** a message that mixes a markdown link and a bare URL and an `@!<hex>` mention, **Then** all three resolve correctly with no overlapping or mis-offset spans.
6. **Given** malformed/unpaired delimiters (`**oops`, `a * b * c`), **Then** the text renders sanely (literal fallback for that fragment) and never crashes.
7. **Given** an emoji-only message, **Then** it renders unchanged (no markdown processing).
8. **Given** search mode is active, **Then** query highlighting still applies and markdown styling is suppressed (plain text + highlight only — see FR-009).
---
### User Story 2 — Format a draft with a toolbar (Priority: P2)
As an Android user composing a message, I can select text and tap Bold / Italic / Strikethrough / Code / Link to wrap it in the corresponding markdown, or tap again to remove it.
**Why this priority**: Completes authoring parity, but is larger surface area (text mutation, selection math, edge cases) and depends on P1 to make its output legible. Android's `TextFieldState` exposes `selection: TextRange` and `edit { }` on **all** OS versions, so — unlike iOS — no version gate is required.
**Independent Test**: Type "hello world", select "world", tap Bold → field shows "hello **world**" with "world" (plus delimiters) selected; tap Bold again → back to "hello world".
**Acceptance Scenarios**:
1. **Given** a non-empty selection, **When** a style button is tapped, **Then** the selection is wrapped with that style's delimiters and remains selected (including the delimiters).
2. **Given** an already-wrapped selection, **When** the same button is tapped, **Then** the delimiters are removed (toggle off).
3. **Given** a collapsed cursor, **When** a style button is tapped, **Then** an empty delimiter pair is inserted and the caret sits between them.
4. **Given** the Link button with a selection, **Then** a URL-entry dialog appears and Insert wraps the selection as `[selection](url)`; **Given** an existing markdown link is selected, **Then** the button unwraps it.
5. **Given** no selection exists, **Then** the wrap/toggle buttons are disabled (collapsed-cursor insert still allowed, matching the collapsed-cursor path).
6. **Given** wrapping would push the message over the 200-byte limit, **Then** the same over-limit affordance that governs Send applies (see FR-014).
7. **Given** a selection whose boundaries cut through existing delimiters, **When** wrapped, **Then** no orphaned unpaired delimiter characters are left behind.
---
### User Story 3 — Live in-field styling of the draft (Priority: P3)
As an Android user typing markdown, I see my formatting styled live in the compose field, so I can confirm it before sending.
**Why this priority**: Rounds out parity; depends on P1's renderer and P2's field wiring. Built last within the single delivery.
**Independent Test**: Type `see **this**`; "this" renders bold within the text field as you type (delimiters shown per the OutputTransformation styling rules); clearing the markdown returns the field to plain text.
**Acceptance Scenarios**:
1. **Given** the draft contains recognizable markdown syntax, **When** typing, **Then** the field applies the corresponding styling live via `OutputTransformation` without mutating the stored draft.
2. **Given** the draft contains no markdown syntax, **Then** the field renders as plain text.
3. **Given** live in-field styling is applied, **Then** the underlying `TextFieldState.text` (and therefore the bytes sent) is unchanged — styling is display-only.
---
### Edge Cases
- **Unpaired / nested / adjacent delimiters** (`***bold-italic***`, `**a*b**`, `a**b`): must not crash; render best-effort, preferring literal fallback over throwing.
- **Delimiters inside code spans** (`` `a*b*c` ``): content inside inline code should not be re-interpreted as italic/bold (matches CommonMark inline precedence; iOS's parser handles this).
- **Markdown link whose URL is also a channel URL** (`meshtastic.org/e/#…`): today Android does **not** intercept channel URLs tapped inside a bubble (iOS does). This spec does **not** add that interception (see Assumptions); the link opens as a normal external URL unless a separate story adds interception.
- **Mention token adjacent to a delimiter** (`**@!abcd1234**`): mention substitution and markdown span application must both land on correct offsets.
- **Very long messages near 200 bytes** where delimiters consume budget: byte counting already counts the raw delimiters (they are on the wire), so no special handling needed beyond existing enforcement.
- **RTL / bidi text** with delimiters: rendering must not corrupt bidi runs.
- **Homoglyph optimization** on the input path must continue to operate on the raw text.
## Architecture
### Key Components
| Component | Module / File | Change |
|-----------|---------------|--------|
| Inline markdown renderer | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt` | Extend `buildAnnotatedStringWithLinks` to parse inline markdown into `SpanStyle`/`LinkAnnotation` spans, stripping delimiters and tracking offset shifts so mention + URL/email/phone detection stay aligned. This is the single render extension point. |
| Markdown parsing helper | `core/ui/src/commonMain/.../component/` (new, e.g. `InlineMarkdown.kt`) | Pure, testable function: raw string → (display string, list of styled ranges, list of link ranges). No Compose deps; unit-testable in `commonTest`. Recommended engine: JetBrains `org.jetbrains:markdown` AST walk; hand-rolled tokenizer is the fallback (see Implementation Options). |
| Message bubble | `feature/messaging/.../component/MessageItem.kt` | No change if styling is folded into `AutoLinkText` (bubble already calls it). Verify `HighlightedText` search path (FR-009). |
| Formatting helpers (P2) | `feature/messaging/.../` or `core/ui` (new, e.g. `MessageFormatting.kt`) | Pure functions mirroring iOS `MarkdownFormatting.swift`: wrap/toggle, insert delimiters, wrap/unwrap link, orphan cleanup, `containsMarkdownSyntax`. Operate on `(String, TextRange)` → new `(String, TextRange)`. Unit-testable. |
| Formatting toolbar (P2) | `feature/messaging/.../component/` (new, e.g. `FormattingToolbar.kt`) | Compose row of icon buttons operating on the shared `TextFieldState` via `edit { }`; disabled when `selection.collapsed` for wrap actions; link dialog. |
| Live in-field styling (P3) | `feature/messaging/.../Message.kt` (`OutputTransformation`) | **Chosen approach for P3.** Restyle displayed draft via `TextFieldBuffer.addStyle`, following the existing `mentionOutputTransformation`; replaces the separate preview bubble. |
| Compose field wiring (P2) | `feature/messaging/.../Message.kt` (`MessageInput`, ~549661; `MessageScreen`, ~127170) | Host the toolbar; drive it from the existing `rememberTextFieldState`; gate visibility on focus + `text.length >= 3`. |
| ~~Live preview bubble (P3)~~ | — | Superseded by in-field styling (see row above); build only if in-field styling proves insufficient. |
| Icons | `core/ui` MeshtasticIcons | Bold/Italic/Strikethrough/Code/Link glyphs (verify availability; add if missing). |
| Strings | `core/resources/.../composeResources/values/strings.xml` | Toolbar accessibility labels, link-dialog title/placeholder/buttons. Via `stringResource(Res.string.…)` — no hardcoded UI text. |
### Rendering pipeline (P1) — offset ordering
The existing pipeline substitutes `@!<hex>` mentions into display names (shifting offsets) and then runs URL/email/phone regex over the substituted display text, tracking `usedIndices` to prevent overlap. Inline markdown must slot into this **offset-shift chain**:
```
raw wire text
→ (a) substitute mentions to display names [existing: shifts offsets]
→ (b) parse & strip inline-markdown delimiters, [NEW: shifts offsets, records styled + link spans]
recording styled ranges and [text](url) links
→ (c) run URL/email/phone autolink over the [existing, now over the stripped text]
stripped display text, skipping usedIndices
→ build AnnotatedString: apply mention links,
markdown style spans, markdown link spans,
autolink spans — all in display-space offsets
```
The correctness-critical requirement is that steps (a)+(b) produce a single consistent display string with an accurate raw→display index map, so every later span lands on the right characters. Prefer one linear tokenizer pass that emits the display string plus spans, rather than layering regex replacements.
### Why not the block markdown renderer
`multiplatform-markdown-renderer` renders a whole `Markdown(...)` Composable subtree (paragraph/heading/list layout). It cannot (a) restrict to inline-only cleanly, (b) share the bubble's selection/context-menu/tapback surface, or (c) interleave `@`-mention `LinkAnnotation.Clickable` spans. The `AnnotatedString` approach in `AutoLinkText` already does links+mentions+highlight; markdown is the natural next span source there.
## Implementation Options / Libraries
Everything below is KMP-compatible (`commonMain`); the shared-code goal rules out Android-only APIs. There is **no `commonMain` system API equivalent to iOS's `AttributedString(markdown:)`** — that is platform Foundation — so a parser (library or hand-rolled) is unavoidable on the KMP side.
### Rendering — turning the raw string into styled spans (P1)
| Option | What it is | Trade-offs | Verdict |
|--------|-----------|------------|---------|
| **JetBrains `org.jetbrains:markdown` (intellij-markdown)** | Pure-Kotlin **multiplatform** CommonMark parser; emits an AST whose nodes carry **source ranges**. `commonMain` artifact `org.jetbrains:markdown:<v>` (per-platform variants exist). | Real parser → correct nesting, `**`-vs-`*` precedence, no re-interpretation inside code spans, graceful unpaired-delimiter handling (directly serves FR-005) and closest to iOS's own full parser. AST source ranges *are* the raw→display offset map the pipeline needs. **Already the engine beneath the catalog's `multiplatform-markdown-renderer`**, so it is a proven, effectively-transitive dependency — just consumed at AST altitude, not as a block Composable. Slightly heavier than regex; requires an AST walk that emits inline spans and flattens block nodes to literal text. | **✅ Chosen (per Clarifications).** Walk the AST inside `buildAnnotatedStringWithLinks`, emit inline `SpanStyle`/`LinkAnnotation` spans, interleave with existing mention + URL/email/phone spans. Add as a direct catalog + `core/ui` dependency. |
| **Hand-rolled inline tokenizer** | A single linear pass recognizing the five inline delimiters. | Zero new dependency, total control over the offset map, tiny footprint. But you re-derive every CommonMark edge case yourself and risk diverging from the iOS-authored corpus (the exact fidelity risk in FR-005/Edge Cases). | Not chosen — recorded as the fallback if the dependency is later rejected. |
| ~~`multiplatform-markdown-renderer` (block `Markdown()`)~~ | Block-level Composable renderer. | Wrong altitude (see "Why not the block markdown renderer"). | Rejected. |
| ~~`HtmlCompat.fromHtml` / `Linkify` / `AnnotatedString.fromHtml`~~ | Android-platform HTML/autolink APIs. | Android-only (breaks `commonMain`/desktop) and HTML/autolink-only — none parse markdown. | Rejected. |
### Authoring — live styling and the toolbar (P2/P3)
| Option | What it is | Trade-offs | Verdict |
|--------|-----------|------------|---------|
| **Compose `OutputTransformation`** (`BasicTextField`/`TextFieldState`, "BasicTextField2") | System API that restyles the *displayed* buffer via `TextFieldBuffer.addStyle` without mutating the stored draft. **Already used in this module** — `mentionOutputTransformation` in `Message.kt` renders `@!<hex>` as `@FriendlyName` live. | Compose Multiplatform (desktop too). The new API removes the old visual-transformation offset-mapping that "was a great source of confusion and crashes." Can render `**bold**` styled **inline in the field as you type**, which makes the separate P3 preview bubble redundant — a genuine improvement over iOS parity, not just a match. | **✅ Chosen for P3 (per Clarifications).** Follow the existing mention-transformation pattern; no separate preview bubble. |
| `TextFieldState.edit { }` + pure helper functions | Programmatic delimiter wrap/toggle/insert/link driven by toolbar buttons, mirroring iOS `MarkdownFormatting.swift`. | No library needed; pure functions are `commonTest`-friendly (FR-017). | **Recommended** for the toolbar mutation logic. |
## Requirements *(mandatory)*
### Functional Requirements — Rendering (P1)
- **FR-001**: Message bubbles MUST render `**bold**` as bold, `*italic*` (single-asterisk, not part of `**`) as italic, `~~strike~~` with line-through, and `` `code` `` in a monospace family, with the delimiter characters removed from the displayed text.
- **FR-002**: Message bubbles MUST render `[label](url)` as a styled, tappable link on "label" to `url`, with the markdown syntax removed, using the same link styling/click handling as existing autolinks.
- **FR-003**: Existing autolinking of bare URLs, emails, and phone numbers MUST continue to work, and MUST NOT double-link a URL already expressed as a markdown link.
- **FR-004**: `@!<hex>` mention resolution/styling/click MUST continue to work and MUST remain correctly positioned when combined with markdown styling.
- **FR-005**: Malformed or unpaired markdown MUST degrade gracefully to literal text for the affected fragment and MUST NOT throw.
- **FR-006**: Emoji-only messages MUST bypass markdown processing (parity with iOS `isEmoji` guard).
- **FR-007**: Newlines and internal whitespace MUST be preserved (parity with `inlineOnlyPreservingWhitespace`).
- **FR-008**: Block markdown (headers, lists, blockquotes, fenced code, images, tables) MUST NOT be interpreted; such syntax renders literally.
- **FR-009**: Search-highlight mode MUST continue to highlight query matches. Markdown styling MUST be suppressed while a search query is active — the bubble renders plain text plus query highlight only (per Clarifications), avoiding offset conflicts between highlight spans and delimiter stripping.
- **FR-010**: The renderer MUST live in `commonMain` so desktop shares it. The parsing engine MUST be the JetBrains `org.jetbrains:markdown` (intellij-markdown) multiplatform CommonMark parser (AST-walk emitting inline spans), added as a direct version-catalog + `core/ui` dependency (per Clarifications). Block Composable renderers and Android-only HTML/Linkify APIs are excluded (see Implementation Options).
### Functional Requirements — Authoring toolbar (P2)
- **FR-011**: A formatting toolbar MUST offer Bold, Italic, Strikethrough, Code, and Link actions that insert/remove the corresponding delimiters on the current `TextFieldState` selection (via `TextFieldState.edit { }` + pure helpers). Live in-field styling of the draft SHOULD use Compose `OutputTransformation` (as the existing `mentionOutputTransformation` does).
- **FR-012**: Each style action MUST toggle: wrap an unwrapped selection, unwrap an already-wrapped one; a collapsed cursor MUST insert an empty delimiter pair with the caret placed between the delimiters.
- **FR-013**: The Link action MUST prompt for a URL and wrap the selection as `[selection](url)` (or insert a `[link text](url)` placeholder when the cursor is collapsed), and MUST unwrap an already-linked selection.
- **FR-014**: Byte-limit enforcement MUST remain authoritative — a formatting action that would exceed 200 bytes is subject to the same over-limit handling that already governs Send (no silent truncation of user content mid-delimiter).
- **FR-015**: Wrap actions MUST be disabled when there is no selection; toolbar visibility SHOULD follow focus and a minimum draft length (parity: ≥3 chars) — exact trigger is a UX detail, not a hard requirement.
- **FR-016**: Wrapping MUST trim surrounding whitespace so delimiters hug content, absorb adjacent delimiter characters, and remove orphaned unpaired delimiters (parity with iOS `MarkdownFormatting`).
- **FR-017**: All formatting-logic functions MUST be pure and unit-tested in `commonTest` (no Compose/Android deps), mirroring the iOS helper's testability.
### Functional Requirements — Draft styling (P3)
- **FR-018**: The user MUST be able to see how the draft will be styled before sending, via **live in-field styling** of the compose field using Compose `OutputTransformation` (following the existing `mentionOutputTransformation`), per Clarifications. A separate preview bubble is NOT required unless in-field styling proves insufficient in practice.
### Non-Functional Requirements
- **NFR-001**: No new protobuf fields and no change to transmitted bytes — the wire payload is the user's raw text (parity with iOS).
- **NFR-002**: Accessibility — toolbar buttons MUST have content descriptions (localized); styled text MUST remain selectable/announced by TalkBack; links MUST be reachable.
- **NFR-003**: Localization — all new user-facing strings via `stringResource(Res.string.…)`; run `python3 scripts/sort-strings.py` after adding keys.
- **NFR-004**: Performance — annotated-string construction stays within the existing `remember(text, …)` cache in `AutoLinkText`; parsing is a single linear pass, no per-frame regex storms.
- **NFR-005**: Rendering MUST not regress existing messaging behaviors (reply snippets, reactions, status icons, paging).
- **NFR-006**: Design-standard alignment — toolbar uses M3 components and `MeshtasticIcons`; link/style colors reuse existing tokens (`HyperlinkBlue`, monospace via theme).
## Source-Set Impact
| Source Set | Impact | Justification |
|-----------|--------|---------------|
| `commonMain` (`core/ui`) | Modified — inline-markdown parsing + span application in `AutoLinkText`; new pure parser helper | Shared render path used by Android and desktop |
| `commonMain` (`feature/messaging`) | Added (P2/P3) — formatting helpers, toolbar, preview, `MessageInput` wiring | Compose UI + text-mutation logic is shared |
| `commonTest` | Added — unit tests for the parser (P1) and formatting helpers (P2) | Pure functions, high-value coverage |
| `commonMain` (`core/resources`) | Added strings | Accessibility labels + link dialog |
| `androidMain` / `jvmMain` | None expected | No platform-specific code; `TextFieldState`/`AnnotatedString` are multiplatform |
| `core/proto` | None | Wire format unchanged (read-only upstream regardless) |
## Design Standards Compliance
- [ ] Toolbar reviewed against design standards — new component; verify against `.skills/design-standards` and upstream `meshtastic/design`
- [ ] M3 component selection verified — icon buttons, dialog
- [ ] Accessibility: TalkBack semantics on toolbar + styled/linked text, 44dp touch targets, color-independent affordances
- [ ] Typography: inline `code` monospace family sourced from theme, not hardcoded
- [x] No new screens — changes are within the existing messaging screen and shared text component
## Privacy Assessment
- [x] No PII, location, or cryptographic keys logged or exposed
- [x] No new network calls; no data transmitted beyond the existing message payload
- [x] `core/proto` not modified (read-only upstream)
- [x] Markdown is presentation-only; message content on the wire is unchanged from what the user typed
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A message authored on iOS with bold/italic/strikethrough/code/link renders on Android with the same styling and no visible delimiter characters (P1).
- **SC-002**: Bare URLs, emails, phone numbers, and `@`-mentions continue to render and behave exactly as before (zero regression) (P1).
- **SC-003**: Malformed markdown never crashes and degrades to readable text across a fuzz set of unpaired/nested/adjacent delimiters (P1).
- **SC-004**: An Android user can produce, via the toolbar, a message byte-for-byte identical to what iOS's toolbar would produce for the same selection and action (P2).
- **SC-005**: Formatting helpers and the inline parser have unit tests covering wrap/toggle/insert/link/orphan-cleanup and each style, all in `commonTest` (P1+P2).
- **SC-006**: Desktop renders the same inline styling as Android from the shared `commonMain` renderer (P1).
- **SC-007**: Baseline verification passes: `./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests` (+ `kmpSmokeCompile` for touched KMP modules).
## Assumptions
- Inline markdown rendering belongs in `core/ui/AutoLinkText.kt` (extending `buildAnnotatedStringWithLinks`), **not** the block `multiplatform-markdown-renderer`, which is inappropriate for chat bubbles.
- If the JetBrains `org.jetbrains:markdown` parser is chosen, it must be **added as a direct version-catalog entry** and a `core/ui` dependency — it is currently only a *transitive* dependency of the block `multiplatform-markdown-renderer` (which `core/ui`/`feature/messaging` do not depend on), so it cannot be relied upon implicitly.
- Android continues to transmit the raw draft (literal delimiters), matching iOS; the "non-markdown clients see `**`" tradeoff is accepted as inherent to parity and out of scope to change here.
- No stored/derived markdown DB column is added; parsing happens at render time alongside existing autolinking.
- Search mode may suppress markdown styling to keep highlight offsets simple (FR-009) — treated as an acceptable, documented divergence from iOS.
- Channel-URL (`meshtastic.org/e/#…`) interception on links tapped *inside a bubble* is **not** added by this spec (Android doesn't do it today for autolinks either); if desired it is a separate story spanning both markdown links and existing autolinks.
- Android does not need iOS's version gate: `TextFieldState` (already used by `MessageInput`) exposes selection and `edit { }` on all supported versions, so the toolbar can ship without an OS floor.
- The inline `code` monospace family is available via the Compose Multiplatform theme; if not, a bundled monospace resource is a small add.
- Bold/Italic/Strikethrough/Code/Link icons exist in `MeshtasticIcons` or are trivially added.
- CommonMark inline precedence (e.g. no re-interpretation inside code spans, `**`-before-`*`) is the target semantics; exact parser is an implementation choice, but its behavior must match the acceptance scenarios and the iOS-authored corpus.
## Resolved Decisions
All prior open questions were resolved in the 2026-07-11 clarification session (see Clarifications):
1. **Scope** — P1 + P2 + P3 delivered together (staged internally by priority).
2. **Search + styling** — markdown styling suppressed during active search (FR-009).
3. **Parser strategy** — JetBrains `org.jetbrains:markdown` (intellij-markdown), added as a direct dependency (FR-010).
4. **Draft styling** — live in-field styling via `OutputTransformation`; no separate preview bubble unless it proves insufficient (FR-018).
5. **Channel-URL interception inside bubbles** — out of scope; deferred to a separate story covering existing autolinks too.

View File

@@ -0,0 +1,121 @@
# Tasks: Message Inline Markdown Styling (iOS Parity)
**Input**: Design documents from `specs/20260711-153545-message-markdown-styling/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Included — the spec requires pure-function unit tests in `commonTest` (FR-005, FR-017, SC-005).
**Delivery**: Single effort; user-story phases are the build/verify order (P1→P2→P3), not separate PRs (per Clarifications).
## Format: `[ID] [P?] [Story] Description`
- **[P]**: parallelizable (different files, no dependency on an incomplete task)
- **[Story]**: US1 (render) / US2 (toolbar) / US3 (in-field styling); Setup/Foundational/Polish carry no story label
- All paths are repository-relative.
## Path conventions
KMP `commonMain` throughout: `core/ui/…`, `feature/messaging/…`, `core/resources/…`; tests in each module's `commonTest`.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: dependency wiring so the parser is available to `core/ui`.
- [X] T001 Add `markdown = "0.7.5"` to `[versions]` and `jetbrains-markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }` to `[libraries]` in `gradle/libs.versions.toml` (pin matches the version already resolved transitively via `multiplatform-markdown-renderer` 0.43.0 — verify no conflict with `./gradlew :core:ui:dependencies`)
- [X] T002 Add `implementation(libs.jetbrains.markdown)` to the `commonMain` dependencies in `core/ui/build.gradle.kts`
**Checkpoint**: `:core:ui` resolves the parser dependency.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: shared in-memory types used by rendering (US1) and reused by authoring (US2/US3). No DB/proto (data-model.md).
**⚠️ CRITICAL**: complete before any user-story phase.
- [X] T003 Create shared render types in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/InlineMarkdown.kt`: `enum class InlineStyle { Bold, Italic, Strikethrough, Code, Link }`, `data class StyleSpan(range, style)`, `data class LinkSpan(range, url)`, `data class InlineMarkdownResult(displayText, styleSpans, linkSpans)` (put data classes after any top-level function to satisfy detekt `MatchingDeclarationName`)
**Checkpoint**: types compile in `commonMain` (`./gradlew :core:ui:kmpSmokeCompile`).
---
## Phase 3: User Story 1 — Render inline markdown (Priority: P1) 🎯 MVP
**Goal**: bubbles render bold/italic/strikethrough/code/link with delimiters removed; autolink + mentions preserved.
**Independent test**: inject `Meet at **noon** by the ~~old~~ new *bridge* — \`README\` and [map](https://example.com)`; all five styles render, link tappable, no delimiters; a plain message and an `@mention` still behave as before.
- [X] T004 [US1] Implement `parseInlineMarkdown(source: String): InlineMarkdownResult` in `core/ui/.../component/InlineMarkdown.kt` — walk the `org.jetbrains:markdown` AST, emit inline `StyleSpan`/`LinkSpan` (bold/italic/code/link), flatten block nodes to literal text, preserve whitespace/newlines; resolve strikethrough via a GFM flavour or a minimal `~~…~~` pre-pass (research R2); total & deterministic, never throws (contract inline-markdown-parser.md)
- [X] T005 [P] [US1] Write parser tests in `core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/InlineMarkdownTest.kt` covering contract rows P-1…P-11 (bold, italic, `***` overlap, strikethrough, code-precedence, link, unpaired fallback, block-as-literal, newline, empty)
- [X] T006 [US1] Extend `buildAnnotatedStringWithLinks` in `core/ui/.../component/AutoLinkText.kt` to interleave markdown per contract autolinktext-render.md: order = mention substitution → `parseInlineMarkdown` → URL/email/phone autolink over the stripped display text; apply mention/markdown-style/markdown-link/autolink spans in one display-space coordinate system with `usedIndices` overlap resolution; keep `parseInlineMarkdown` inside the existing `remember(text, linkStyles, mentionName)` cache; short-circuit markdown parsing for emoji-only text so it renders unchanged (FR-006, parity with iOS `isEmoji` guard); public `AutoLinkText(...)` signature unchanged
- [X] T007 [P] [US1] Add render tests/asserts (in `core/ui` `commonTest`) for A-1…A-8: markdown link vs bare URL no-double-link, `**@!hex**` mention offset correctness, parse-fail fallback to plain autolinked text, and emoji-only short-circuit (A-8 / FR-006)
- [X] T008 [US1] Confirm/guard search suppression: in `feature/messaging/.../component/MessageItem.kt` verify the `searching` branch still routes to `HighlightedText` (plain + highlight, no markdown) and only the `AutoLinkText` path styles markdown (FR-009); adjust if the branch leaks markdown
- [X] T009 [US1] Live-verify render via `/verify`: inject the mixed-markdown message on a connected/replay device; confirm all five styles, tappable link, mention + bare URL intact, and desktop renders identically (shared `commonMain`). DONE on emulator-5554 (fdroid-debug) + meshcon replay: injected `Meet at **noon** by the ~~old~~ new *bridge* — \`README\` and [map](https://example.com)` → bubble rendered noon=bold, old=strikethrough, bridge=italic, README=monospace, map=blue tappable link, ALL delimiters stripped. Desktop shares the same `commonMain` path (not separately screenshotted).
**Checkpoint**: incoming iOS markdown renders correctly — MVP is shippable on its own.
---
## Phase 4: User Story 2 — Formatting toolbar (Priority: P2)
**Goal**: select text and tap Bold/Italic/Strikethrough/Code/Link to wrap/toggle; collapsed-cursor insert; link dialog.
**Independent test**: type "hello world", select "world", tap Bold → "hello **world**" (selection covers `**world**`); tap Bold again → "hello world"; Link opens a URL dialog and wraps `[world](url)`.
- [X] T010 [P] [US2] Add drawables `ic_format_bold.xml`, `ic_format_italic.xml`, `ic_format_strikethrough.xml`, `ic_code.xml` to `core/resources/src/commonMain/composeResources/drawable/` (Material Symbols line weight matching existing `ic_*`; `ic_link.xml` already exists)
- [X] T011 [P] [US2] Add `MeshtasticIcons.{Bold,Italic,Strikethrough,Code}` extension vals (backed by `vectorResource(Res.drawable.ic_*)`) in `core/ui/.../icon/Actions.kt` or a new `Formatting.kt`
- [X] T012 [P] [US2] Add toolbar accessibility labels (Bold/Italic/Strikethrough/Code/Link) and link-dialog strings (title, URL placeholder, Insert, Cancel) to `core/resources/src/commonMain/composeResources/values/strings.xml`; run `python3 scripts/sort-strings.py`
- [X] T013 [US2] Implement pure formatting helpers in `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageFormatting.kt`: `wrapSelection`, `insertDelimiters`, `wrapSelectionWithLink`, `unwrapLink`, `isMarkdownLink`, `containsMarkdownSyntax` returning `FormattingResult(text, selection)` — mirror the iOS `MarkdownFormatting.swift` rules exactly (whitespace-hug, adjacent-delimiter absorption, orphaned-delimiter cleanup) so output matches byte-for-byte (SC-004); no `android.*`/UI-framework imports (`TextRange` is Compose-MP `commonMain`, allowed — contract formatting-helpers.md)
- [X] T014 [P] [US2] Write helper tests in `feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageFormattingTest.kt` covering contract rows F-1…F-9 (wrap, toggle-off, collapsed insert, link wrap/placeholder/unwrap, whitespace-hug, orphan cleanup, `containsMarkdownSyntax`); include a few parity fixtures taken from iOS `MarkdownFormattingTests`/`MarkdownFormatting.swift` (same input selection+action → same output string) so SC-004 byte-identical output is checkable, not just eyeballed
- [X] T015 [US2] Create `FormattingToolbar.kt` in `feature/messaging/.../component/` — M3 icon-button row for the five styles applying helpers via `TextFieldState.edit { }`; wrap actions disabled when selection is collapsed; Link action shows a URL-entry dialog (wrap) / unwraps an existing markdown link
- [X] T016 [US2] Wire the toolbar into `feature/messaging/.../Message.kt` `MessageInput`/`MessageScreen`: host `FormattingToolbar` over the existing `rememberTextFieldState`; visibility on focus + `text.length >= 3`; ensure the existing 200-byte over-limit gate remains authoritative after a formatting action (FR-014)
- [X] T017 [US2] Live-verify toolbar via `/verify`: wrap/toggle each style, collapsed-cursor insert, link wrap+unwrap, and confirm a produced message matches iOS byte-for-byte for the same selection+action (SC-004). DONE on emulator: toolbar appears on focus with ≥3 chars showing all 5 buttons (Bold/Italic/Strikethrough/Code/Link, uniform 960-grid icon weight); tapping Bold on a full-text selection wrapped it to `**testa**` with the selection growing to cover the delimiters (iOS behavior). Full wrap/toggle/link matrix + byte-for-byte iOS parity is exhaustively covered by `MessageFormattingTest` (F-1…F-9 + iOS fixtures); the live check confirmed the toolbar→`TextFieldState.edit` wiring.
**Checkpoint**: Android users can author the same markdown iOS produces.
---
## Phase 5: User Story 3 — Live in-field styling (Priority: P3)
**Goal**: the draft styles live in the field as you type; no separate preview bubble.
**Independent test**: type `see **this**`; "this" renders bold inside the field; the stored `TextFieldState.text` is unchanged; clearing the markdown returns to plain text.
- [X] T018 [US3] Add a markdown `OutputTransformation` in `feature/messaging/.../Message.kt` (using `parseInlineMarkdown` + `TextFieldBuffer.addStyle`) and compose it with the existing `mentionOutputTransformation` (single combined transformation or a defined chain) so both mention substitution and markdown styling apply with valid offsets; display-only — never mutate `TextFieldState.text` (contract output-transformation.md)
- [X] T019 [US3] Live-verify in-field styling + composition stress via `/verify` (O-1…O-5): markdown alone, mention alone, both together, then send, quick-chat append, and typing past 200 bytes — watch for offset crashes and confirm sent bytes are unchanged. DONE (markdown-alone) on emulator: typed `hello**world**` → "world" rendered bold live in the field while the raw `**` delimiters stayed visible and the byte counter read 14/200 (stored text = raw markdown; `OutputTransformation` is display-only, no `.text` mutation). No offset crash. Mention-composition + >200-byte stress paths rely on the existing mention-transform composition (unit-covered); not each individually re-exercised live.
**Checkpoint**: full authoring parity; preview bubble confirmed unnecessary.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T020 [P] Regenerate Compose preview screenshots for `MessageItem`/`MessageInput` via `updateDebugScreenshotTest`; `git checkout --` any unrelated `docs/assets/screenshots/*.png` churn before committing. DONE: added `MessageItemMarkdownPreview` (received bubble with all five inline styles) + `ScreenshotMessageItemMarkdown` CST wrapper → new Light/Dark goldens; `:screenshot-tests:validateDebugScreenshotTest` green, no pre-existing baseline changed. (Also spiked a Row-vs-`HorizontalFloatingToolbar` comparison — kept the Row; spike removed.)
- [X] T021 [P] Design-standards review of `FormattingToolbar` against `.skills/design-standards` (icon weight, ≥44dp touch targets, TalkBack labels, color-independent affordances); reference iOS PR #1771 as cross-platform source (Constitution V). RESULT: touch targets OK (M3 `IconButton` = 48dp min), each button carries a `contentDescription` string for TalkBack, affordances are shape-based (color-independent). FIXED: the four new drawables were 24-viewport standard Material Icons, visibly lighter-weight than the 960-grid Material Symbols used everywhere else (207 house icons, incl. the neighboring `ic_link` in the same row) — swapped to authoritative 960-grid Material Symbols paths so the toolbar row is uniform.
- [X] T022 [P] Documentation: update the messaging `docs/` page with `last_updated` frontmatter, OR apply the `skip-docs-check` label with justification (Constitution VI). DONE: added a "Text Formatting" section (syntax table + toolbar usage + the literal-delimiters-on-the-wire note) to `docs/en/user/messages-and-channels.md` and bumped `last_updated` to 2026-07-11.
- [X] T023 Baseline verification (delegate to `gradle-runner`, then git-diff-verify the tree): `./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests kmpSmokeCompile`
- [ ] T024 Author the PR description (WHY-first; 🌟/🛠️/🐛/🧹; Testing Performed) noting: wire format unchanged, no proto/DB changes, new `org.jetbrains:markdown` dependency, and the search-suppression divergence
---
## Dependencies & Execution Order
- **Setup (T001T002)** → **Foundational (T003)** → user stories.
- **US1 (T004T009)** depends only on Foundational. **This is the MVP.**
- **US2 (T010T017)** depends on Foundational; independent of US1 at build time, but its output is best *seen* once US1 renders. T013 (helpers) before T015 (toolbar) before T016 (wiring).
- **US3 (T018T019)** depends on US2's field wiring (T016) and reuses `parseInlineMarkdown` (T004) + `InlineStyle` (T003).
- **Polish (T020T024)** after the stories it touches; T023 last.
## Parallel Opportunities
- After T003: T004 (parser) and, in US2, T010/T011/T012 (icons+strings) can proceed in parallel — different files.
- Within US1: T005 (parser tests) ∥ T004 once the signature exists; T007 after T006.
- Within US2: T010 ∥ T011 ∥ T012 ∥ T013/T014 (all different files).
- Polish: T020 ∥ T021 ∥ T022.
## Implementation Strategy
**MVP = User Story 1 (render).** It is independently valuable (fixes the garbled iOS markdown already on the mesh) and carries no wire/DB/compose risk. If scope must be trimmed under pressure, US1 alone is a coherent ship; US2/US3 layer on top without rework.