Skip to content

Commit 830e096

Browse files
committed
fix(messaging): preserve markdown styling through mentions
Build mention replacements and inline-style spans from the immutable source buffer before applying presentation edits. Remap style boundaries through length-changing friendly-name substitutions so replacing @!<hex> tokens cannot shift or discard surrounding markdown. Keep presentation-only transformations from changing the wire text, and prevent display names or delimiters inside code spans from introducing unintended bold, italic, or strikethrough styling. Use deterministic span ordering and lint-clean boundary traversal for overlapping syntax cases. Cover mentions nested within markdown, markdown contained by mention substitutions, delimiter-bearing display names, code-span isolation, and replacement length changes. Keep candidate terminology aligned throughout the transformation pipeline.
1 parent 4274280 commit 830e096

2 files changed

Lines changed: 144 additions & 12 deletions

File tree

feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -585,37 +585,107 @@ internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle)
585585
*/
586586
internal fun liveInlineMarkdownStyleRanges(source: String): List<LiveStyleSpan> {
587587
if (!source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList()
588+
val codeMatches = LIVE_CODE.findAll(source).toList()
589+
// Returns true when either delimiter boundary of this match falls inside a code span. Markdown entirely inside
590+
// code, or malformed markdown crossing a code boundary, must not add styling. A code span nested inside outer
591+
// emphasis is still allowed, so outer bold text can retain both its bold span and an inner code span.
592+
val isBlockedByCodeSpan: (MatchResult) -> Boolean = { match ->
593+
codeMatches.any { codeMatch -> match.range.first in codeMatch.range || match.range.last in codeMatch.range }
594+
}
588595
return buildList {
589-
LIVE_BOLD.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold)) }
590-
LIVE_ITALIC.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic)) }
591-
LIVE_STRIKE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough)) }
592-
LIVE_CODE.findAll(source).forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) }
596+
LIVE_BOLD.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
597+
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold))
598+
}
599+
LIVE_ITALIC.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
600+
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic))
601+
}
602+
LIVE_STRIKE.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
603+
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough))
604+
}
605+
codeMatches.forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) }
593606
}
607+
.sortedWith(compareBy<LiveStyleSpan>({ it.range.first }, { it.range.last }, { it.style.ordinal }))
594608
}
595609

596610
private val LIVE_STYLE_DELIMITER_SET = setOf('*', '~', '`')
597611

612+
internal data class MentionReplacement(val range: IntRange, val text: String)
613+
614+
internal data class MentionOutputPlan(val replacements: List<MentionReplacement>, val styleSpans: List<LiveStyleSpan>)
615+
616+
/**
617+
* Builds presentation edits from the immutable source buffer. Markdown is parsed before friendly-name replacement so
618+
* display names containing `*`, `~`, or backticks cannot introduce formatting that was not present in the stored text.
619+
*/
620+
internal fun mentionOutputPlan(source: String, candidatesById: Map<String, MentionCandidate>): MentionOutputPlan {
621+
val replacements =
622+
MENTION_TOKEN_REGEX.findAll(source)
623+
.mapNotNull { match ->
624+
val candidate = candidatesById[match.groupValues[1]] ?: return@mapNotNull null
625+
MentionReplacement(match.range, "@" + candidate.longName.ifEmpty { candidate.shortName })
626+
}
627+
.toList()
628+
val styleSpans = remapStyleSpans(liveInlineMarkdownStyleRanges(source), replacements)
629+
return MentionOutputPlan(replacements, styleSpans)
630+
}
631+
632+
/** Remaps source-buffer style ranges through non-overlapping mention replacements. */
633+
internal fun remapStyleSpans(spans: List<LiveStyleSpan>, replacements: List<MentionReplacement>): List<LiveStyleSpan> {
634+
if (spans.isEmpty() || replacements.isEmpty()) return spans
635+
val sortedReplacements = replacements.sortedBy { it.range.first }
636+
return spans.mapNotNull { span ->
637+
val start = remapBoundary(span.range.first, sortedReplacements, preferReplacementEnd = false)
638+
val endExclusive = remapBoundary(span.range.last + 1, sortedReplacements, preferReplacementEnd = true)
639+
if (start >= endExclusive) null else span.copy(range = start until endExclusive)
640+
}
641+
}
642+
643+
private fun remapBoundary(
644+
sourceOffset: Int,
645+
replacements: List<MentionReplacement>,
646+
preferReplacementEnd: Boolean,
647+
): Int {
648+
var delta = 0
649+
var mappedOffset: Int? = null
650+
val iterator = replacements.iterator()
651+
while (iterator.hasNext() && mappedOffset == null) {
652+
val replacement = iterator.next()
653+
val sourceStart = replacement.range.first
654+
val sourceEndExclusive = replacement.range.last + 1
655+
mappedOffset =
656+
when {
657+
sourceOffset <= sourceStart -> sourceOffset + delta
658+
659+
sourceOffset < sourceEndExclusive ->
660+
sourceStart + delta + if (preferReplacementEnd) replacement.text.length else 0
661+
662+
else -> {
663+
delta += replacement.text.length - (sourceEndExclusive - sourceStart)
664+
null
665+
}
666+
}
667+
}
668+
return mappedOffset ?: sourceOffset + delta
669+
}
670+
598671
/**
599672
* Displays `@!<hex>` tokens as `@FriendlyName` and applies live inline-markdown styling (bold/italic/strikethrough/
600673
* code) while typing. Both are presentation-only via [OutputTransformation]: the stored buffer keeps the hex wire form
601674
* and the raw markdown delimiters, so the bytes sent are unchanged.
602675
*/
603676
@OptIn(ExperimentalFoundationApi::class)
604-
private fun mentionOutputTransformation(nodesById: Map<String, MentionCandidate>) = OutputTransformation {
677+
private fun mentionOutputTransformation(candidatesById: Map<String, MentionCandidate>) = OutputTransformation {
605678
val source = toString()
606679
// Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning.
607680
val hasMention = source.indexOf('@') >= 0
608681
val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET }
609682
if (!needsStyle) return@OutputTransformation
610683

611-
for (match in MENTION_TOKEN_REGEX.findAll(source).toList().asReversed()) {
612-
val candidate = nodesById[match.groupValues[1]] ?: continue
613-
replace(match.range.first, match.range.last + 1, "@" + candidate.longName.ifEmpty { candidate.shortName })
684+
val plan = mentionOutputPlan(source, candidatesById)
685+
for (replacement in plan.replacements.asReversed()) {
686+
replace(replacement.range.first, replacement.range.last + 1, replacement.text)
614687
}
615-
// Rescan the post-replacement buffer — mention replacements may have changed text length,
616-
// so the original source offsets are no longer valid.
617-
val postMentions = toString()
618-
for (span in liveInlineMarkdownStyleRanges(postMentions)) {
688+
for (span in plan.styleSpans) {
619689
val spanStyle =
620690
when (span.style) {
621691
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)

feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,68 @@ class MessageComposerBehaviorTest {
6666
)
6767
}
6868

69+
@Test
70+
fun `live markdown spans are returned in source order`() {
71+
val source = "`code` before **bold**"
72+
val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
73+
74+
assertEquals(listOf("code" to InlineStyle.Code, "bold" to InlineStyle.Bold), spans)
75+
}
76+
77+
@Test
78+
fun `live markdown parser ignores style delimiters inside code spans`() {
79+
val source = "`**not bold** *not italic* ~~not strike~~`"
80+
val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
81+
82+
assertEquals(listOf("**not bold** *not italic* ~~not strike~~" to InlineStyle.Code), spans)
83+
}
84+
85+
@Test
86+
fun `live markdown parser preserves style surrounding code spans`() {
87+
val source = "**before `code` after**"
88+
val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
89+
90+
assertEquals(listOf("before `code` after" to InlineStyle.Bold, "code" to InlineStyle.Code), spans)
91+
}
92+
93+
@Test
94+
fun `mention replacement remaps later markdown span`() {
95+
val source = "@!aabbccdd **bold**"
96+
val candidate = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A")
97+
val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
98+
99+
assertEquals(listOf(MentionReplacement(0..9, "@A")), plan.replacements)
100+
assertEquals(listOf(LiveStyleSpan(5..8, InlineStyle.Bold)), plan.styleSpans)
101+
}
102+
103+
@Test
104+
fun `multiple mention replacements accumulate offsets before later markdown`() {
105+
val source = "@!aabbccdd and @!11223344 then `code`"
106+
val alpha = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A")
107+
val bravo = MentionCandidate(id = "!11223344", longName = "Long Bravo", shortName = "B")
108+
val plan = mentionOutputPlan(source, mapOf(alpha.id to alpha, bravo.id to bravo))
109+
110+
assertEquals(listOf(LiveStyleSpan(25..28, InlineStyle.Code)), plan.styleSpans)
111+
}
112+
113+
@Test
114+
fun `markdown surrounding mention expands to friendly display name`() {
115+
val source = "**@!aabbccdd**"
116+
val candidate = MentionCandidate(id = "!aabbccdd", longName = "Alpha", shortName = "A")
117+
val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
118+
119+
assertEquals(listOf(LiveStyleSpan(2..7, InlineStyle.Bold)), plan.styleSpans)
120+
}
121+
122+
@Test
123+
fun `friendly mention delimiters do not introduce markdown styling`() {
124+
val source = "@!aabbccdd plain"
125+
val candidate = MentionCandidate(id = "!aabbccdd", longName = "**Alpha**", shortName = "A")
126+
val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
127+
128+
assertTrue(plan.styleSpans.isEmpty())
129+
}
130+
69131
@Test
70132
fun `bold delimiters are not double counted as italic`() {
71133
val spans = liveInlineMarkdownStyleRanges("**bold**")

0 commit comments

Comments
 (0)