From 9bf32fb77e1673b4a716731ad8e5b1853ae21507 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Thu, 22 Oct 2026 16:25:27 +0530 Subject: [PATCH 1/3] Hide markdown markers in the Jetchat message input --- .../example/compose/jetchat/UserInputTest.kt | 42 ++++++++++++++++- .../jetchat/conversation/RichTextState.kt | 7 ++- .../compose/jetchat/conversation/UserInput.kt | 46 +++++++++++++++++-- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt index 4c5865190a..3d376d7621 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -17,12 +17,15 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -30,7 +33,9 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.AnnotatedString import androidx.test.espresso.Espresso import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.KeyboardShownKey @@ -133,6 +138,39 @@ class UserInputTest { findSendButton().assertIsEnabled() } + @Test + fun markdownMarkers_hiddenOnScreenButKeptInText() { + findTextInputField().performTextInput("*Hello*") + + findTextInputField().assertTextEquals("Hello") + assertRawText("*Hello*") + } + + @Test + fun typingAfterHiddenMarker_keepsTheMarker() { + findTextInputField().performTextInput("*Hello*") + // Display offset 5 is right after "Hello"; it maps onto the hidden closing '*', as a tap does. + // Move away first: setting the selection it already has is a no-op. + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(0, 0, false) } + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(5, 5, false) } + findTextInputField().performTextInput(" world") + + assertRawText("*Hello* world") + } + + @Test + fun typingBetweenTouchingTokens_keepsBothMarkers() { + findTextInputField().performTextInput("*a*_b_") + // Display offset 1 (between "a" and "b") maps onto the hidden '*' and '_' together. + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(1, 1, false) } + findTextInputField().performTextInput("x") + + assertRawText("*a*x_b_") + } + + private fun assertRawText(text: String) = findTextInputField() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.InputText, AnnotatedString(text))) + private fun clickOnTextField() = composeTestRule .onNodeWithContentDescription(activity.getString(R.string.textfield_desc)) .performClick() @@ -157,7 +195,7 @@ class UserInputTest { private fun findTextInputField(): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and - hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))), + hasContentDescription(activity.getString(R.string.textfield_desc)), ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt index 7566009ea0..64d10f2cd2 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.then import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -92,6 +93,8 @@ fun rememberFormattedTextState(initialText: String = ""): FormattedTextState = r /** * A rich-text [BasicTextField] wrapper that accepts a [FormattedTextState]. + * + * @param inputTransformation runs after the [state]'s own style-override transformation. */ @Composable fun FormattedTextField( @@ -103,11 +106,13 @@ fun FormattedTextField( lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, cursorBrush: Brush = SolidColor(Color.Black), outputTransformation: OutputTransformation? = null, + inputTransformation: InputTransformation? = null, ) { BasicTextField( state = state.textFieldState, modifier = modifier, - inputTransformation = state.inputTransformation, + inputTransformation = inputTransformation?.let { state.inputTransformation.then(it) } + ?: state.inputTransformation, textStyle = textStyle, keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index a88d2c931e..7e52cdc0bc 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -58,6 +58,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll @@ -111,6 +112,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -823,17 +825,29 @@ private fun BoxScope.UserInputTextField( val codeBackground = MaterialTheme.colorScheme.surface val markdownStyling = remember(codeBackground) { val codeStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground) - // Same syntax as the message bubbles (MessageFormatter). Markers stay visible while typing. + // Same syntax as the message bubbles (MessageFormatter). Markers are hidden on screen only, + // so the sent text keeps them and the bubble formats it the same way. OutputTransformation { - symbolPattern.findAll(asCharSequence()).forEach { match -> + val text = asCharSequence().toString() + var removed = 0 + symbolPattern.findAll(text).forEach { match -> val style = when (match.value.first()) { '@', '*' -> InlineStyle.Bold.spanStyle '_' -> InlineStyle.Italic.spanStyle '~' -> InlineStyle.Strikethrough.spanStyle '`' -> codeStyle else -> null // URLs + } ?: return@forEach + val start = match.range.first - removed + val end = match.range.last + 1 - removed + if (match.hidesMarkers()) { + replace(end - 1, end, "") + replace(start, start + 1, "") + addStyle(style, start, end - 2) + removed += 2 + } else { + addStyle(style, start, end) } - style?.let { addStyle(it, match.range.first, match.range.last + 1) } } } } @@ -861,6 +875,7 @@ private fun BoxScope.UserInputTextField( color = MaterialTheme.colorScheme.primary, ), outputTransformation = markdownStyling, + inputTransformation = SkipHiddenMarkers, ) if (formattedTextState.isEmpty && !focusState) { @@ -873,6 +888,31 @@ private fun BoxScope.UserInputTextField( } } +/** `*bold*`, `_italic_`, `~strike~` and `` `code` `` hide their markers in the input field. */ +private fun MatchResult.hidesMarkers() = value.first() in "*_~`" + +/** + * A tap next to hidden markers maps to a selection of just those markers (2 when tokens touch, as + * in `*a*_b_`), so the next keystroke would overwrite them. Collapse it to between the tokens: + * after closing markers, before opening ones. + */ +private val SkipHiddenMarkers = InputTransformation { + if (changes.changeCount != 0 || selection.collapsed) return@InputTransformation + val opening = mutableSetOf() + val closing = mutableSetOf() + symbolPattern.findAll(asCharSequence().toString()).forEach { match -> + if (match.hidesMarkers()) { + opening += match.range.first + closing += match.range.last + } + } + if ((selection.min until selection.max).all { it in opening || it in closing }) { + var cursor = selection.min + while (cursor in closing) cursor++ + selection = TextRange(cursor) + } +} + @Composable fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) { var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } From 21a5ee8be470c0049dc87a02a994c55c2a86ada2 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Thu, 22 Oct 2026 17:55:16 +0530 Subject: [PATCH 2/3] Fix Jetchat backstack test on API 31+ --- .../com/example/compose/jetchat/NavigationTest.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index ea71e5e6e3..2638b8df54 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight +import androidx.lifecycle.Lifecycle import androidx.test.espresso.Espresso -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -79,12 +79,13 @@ class NavigationTest { // Chewie, we're home assertConversationIsDisplayed() - // The profiles were popped off the back stack, so pressing back leaves the app - val activity = composeTestRule.activity + // The profiles were popped off the back stack, so pressing back leaves the app. API 31+ + // moves a root launcher activity to the background instead of finishing it, so check + // that the activity stopped rather than that it is finishing. + val scenario = composeTestRule.activityRule.scenario Espresso.pressBackUnconditionally() - composeTestRule.runOnIdle { - assertTrue(activity.isFinishing) - } + // Stopping waits on the launcher, which can take a few seconds on slow CI emulators. + composeTestRule.waitUntil(timeoutMillis = 5_000) { !scenario.state.isAtLeast(Lifecycle.State.STARTED) } } private fun assertConversationIsDisplayed() { From cd7b86886add0c0bd94a4805d01b86f209ab40ed Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Thu, 22 Oct 2026 16:25:27 +0530 Subject: [PATCH 3/3] Hide markdown markers in the Jetchat message input --- .../example/compose/jetchat/UserInputTest.kt | 42 ++++++++++++++++- .../jetchat/conversation/RichTextState.kt | 7 ++- .../compose/jetchat/conversation/UserInput.kt | 46 +++++++++++++++++-- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt index 4c5865190a..3d376d7621 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -17,12 +17,15 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -30,7 +33,9 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.AnnotatedString import androidx.test.espresso.Espresso import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.KeyboardShownKey @@ -133,6 +138,39 @@ class UserInputTest { findSendButton().assertIsEnabled() } + @Test + fun markdownMarkers_hiddenOnScreenButKeptInText() { + findTextInputField().performTextInput("*Hello*") + + findTextInputField().assertTextEquals("Hello") + assertRawText("*Hello*") + } + + @Test + fun typingAfterHiddenMarker_keepsTheMarker() { + findTextInputField().performTextInput("*Hello*") + // Display offset 5 is right after "Hello"; it maps onto the hidden closing '*', as a tap does. + // Move away first: setting the selection it already has is a no-op. + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(0, 0, false) } + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(5, 5, false) } + findTextInputField().performTextInput(" world") + + assertRawText("*Hello* world") + } + + @Test + fun typingBetweenTouchingTokens_keepsBothMarkers() { + findTextInputField().performTextInput("*a*_b_") + // Display offset 1 (between "a" and "b") maps onto the hidden '*' and '_' together. + findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(1, 1, false) } + findTextInputField().performTextInput("x") + + assertRawText("*a*x_b_") + } + + private fun assertRawText(text: String) = findTextInputField() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.InputText, AnnotatedString(text))) + private fun clickOnTextField() = composeTestRule .onNodeWithContentDescription(activity.getString(R.string.textfield_desc)) .performClick() @@ -157,7 +195,7 @@ class UserInputTest { private fun findTextInputField(): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and - hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))), + hasContentDescription(activity.getString(R.string.textfield_desc)), ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt index 7566009ea0..64d10f2cd2 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.then import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -92,6 +93,8 @@ fun rememberFormattedTextState(initialText: String = ""): FormattedTextState = r /** * A rich-text [BasicTextField] wrapper that accepts a [FormattedTextState]. + * + * @param inputTransformation runs after the [state]'s own style-override transformation. */ @Composable fun FormattedTextField( @@ -103,11 +106,13 @@ fun FormattedTextField( lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, cursorBrush: Brush = SolidColor(Color.Black), outputTransformation: OutputTransformation? = null, + inputTransformation: InputTransformation? = null, ) { BasicTextField( state = state.textFieldState, modifier = modifier, - inputTransformation = state.inputTransformation, + inputTransformation = inputTransformation?.let { state.inputTransformation.then(it) } + ?: state.inputTransformation, textStyle = textStyle, keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index a88d2c931e..7e52cdc0bc 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -58,6 +58,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll @@ -111,6 +112,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -823,17 +825,29 @@ private fun BoxScope.UserInputTextField( val codeBackground = MaterialTheme.colorScheme.surface val markdownStyling = remember(codeBackground) { val codeStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground) - // Same syntax as the message bubbles (MessageFormatter). Markers stay visible while typing. + // Same syntax as the message bubbles (MessageFormatter). Markers are hidden on screen only, + // so the sent text keeps them and the bubble formats it the same way. OutputTransformation { - symbolPattern.findAll(asCharSequence()).forEach { match -> + val text = asCharSequence().toString() + var removed = 0 + symbolPattern.findAll(text).forEach { match -> val style = when (match.value.first()) { '@', '*' -> InlineStyle.Bold.spanStyle '_' -> InlineStyle.Italic.spanStyle '~' -> InlineStyle.Strikethrough.spanStyle '`' -> codeStyle else -> null // URLs + } ?: return@forEach + val start = match.range.first - removed + val end = match.range.last + 1 - removed + if (match.hidesMarkers()) { + replace(end - 1, end, "") + replace(start, start + 1, "") + addStyle(style, start, end - 2) + removed += 2 + } else { + addStyle(style, start, end) } - style?.let { addStyle(it, match.range.first, match.range.last + 1) } } } } @@ -861,6 +875,7 @@ private fun BoxScope.UserInputTextField( color = MaterialTheme.colorScheme.primary, ), outputTransformation = markdownStyling, + inputTransformation = SkipHiddenMarkers, ) if (formattedTextState.isEmpty && !focusState) { @@ -873,6 +888,31 @@ private fun BoxScope.UserInputTextField( } } +/** `*bold*`, `_italic_`, `~strike~` and `` `code` `` hide their markers in the input field. */ +private fun MatchResult.hidesMarkers() = value.first() in "*_~`" + +/** + * A tap next to hidden markers maps to a selection of just those markers (2 when tokens touch, as + * in `*a*_b_`), so the next keystroke would overwrite them. Collapse it to between the tokens: + * after closing markers, before opening ones. + */ +private val SkipHiddenMarkers = InputTransformation { + if (changes.changeCount != 0 || selection.collapsed) return@InputTransformation + val opening = mutableSetOf() + val closing = mutableSetOf() + symbolPattern.findAll(asCharSequence().toString()).forEach { match -> + if (match.hidesMarkers()) { + opening += match.range.first + closing += match.range.last + } + } + if ((selection.min until selection.max).all { it in opening || it in closing }) { + var cursor = selection.min + while (cursor in closing) cursor++ + selection = TextRange(cursor) + } +} + @Composable fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) { var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }