From 1024323661be48ce63d2187f9d8fec0c21030bf0 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:09:10 +0300 Subject: [PATCH 1/2] Send a message with Enter on a physical keyboard, Shift+Enter for a new line With a hardware keyboard attached, Enter inserted a newline and the only way to send was to reach for the send button, which is the opposite of what every desktop chat client does. MarkdownEditText gains an optional Enter listener and consumes Enter when it comes from a physical keyboard without Shift. Shift+Enter keeps inserting a newline, and a key event from the on-screen keyboard is identified by its VIRTUAL_KEYBOARD device id and left alone, so nothing changes for touch users. The key release is consumed as well, so no newline leaks out on key repeat. TextComposer passes its existing send lambda through, guarded by the same canSendTextMessage check the send button already uses, so Enter cannot send an empty message. Relates to https://github.com/element-hq/element-x-android/issues/860 --- .../libraries/textcomposer/TextComposer.kt | 5 +- .../components/markdown/MarkdownEditText.kt | 27 ++++++++ .../components/markdown/MarkdownTextInput.kt | 8 +++ .../markdown/MarkdownTextInputTest.kt | 68 +++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 0f62eb5234..91b897c284 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -160,6 +160,8 @@ fun TextComposer( } else { stringResource(id = R.string.rich_text_editor_composer_placeholder) } + val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment + val textInput: @Composable () -> Unit = when (state) { is TextEditorState.Rich -> { val coroutineScope = rememberCoroutineScope() @@ -218,14 +220,13 @@ fun TextComposer( onReceiveSuggestion = onReceiveSuggestion, richTextEditorStyle = style, onSelectRichContent = onSelectRichContent, + onSendMessage = { if (canSendTextMessage) onSendMessage() }, ) } } } } - val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment - val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { @Composable { TextFormatting(state = it.richTextEditorState) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt index bd808c9acb..b8d173aaa8 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -9,6 +9,8 @@ package io.element.android.libraries.textcomposer.components.markdown import android.content.Context +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.View import androidx.appcompat.widget.AppCompatEditText @@ -17,8 +19,33 @@ internal class MarkdownEditText( ) : AppCompatEditText(context) { var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + /** + * Invoked when Enter is pressed on a physical keyboard without Shift. Return true to consume the + * key, i.e. to send instead of inserting a newline. + */ + var onEnterKeyListener: (() -> Boolean)? = null + private var isModifyingText = false + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_ENTER && + !event.isShiftPressed && + // A soft keyboard must keep inserting a newline, only a real one sends. + event.deviceId != KeyCharacterMap.VIRTUAL_KEYBOARD + ) { + val listener = onEnterKeyListener + if (listener != null) { + return when (event.action) { + // Consume the release too, so no newline leaks out on key repeat. + KeyEvent.ACTION_UP -> true + KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) listener() else true + else -> super.dispatchKeyEvent(event) + } + } + } + return super.dispatchKeyEvent(event) + } + fun updateEditableText(charSequence: CharSequence) { isModifyingText = true editableText.clear() diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 064ef03106..adb026d812 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -54,6 +54,7 @@ fun MarkdownTextInput( onReceiveSuggestion: (Suggestion?) -> Unit, richTextEditorStyle: RichTextEditorStyle, onSelectRichContent: ((Uri) -> Unit)?, + onSendMessage: (() -> Unit)? = null, ) { // Copied from io.element.android.wysiwyg.internal.utils.UriContentListener class ReceiveUriContentListener( @@ -132,6 +133,13 @@ fun MarkdownTextInput( update = { editText -> editText.contentDescription = placeholder editText.applyStyleInCompose(richTextEditorStyle) + // Set here rather than in the factory, which runs once and would capture a stale lambda. + editText.onEnterKeyListener = onSendMessage?.let { send -> + { + send() + true + } + } val text = state.text.value() mentionSpanUpdater.updateMentionSpans(text) if (state.text.needsDisplaying()) { diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index ba49428cae..49206775ac 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -10,6 +10,8 @@ package io.element.android.libraries.textcomposer.impl.components.markdown +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.widget.EditText import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest @@ -32,12 +34,51 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState +import io.element.android.tests.testutils.EnsureCalledOnce import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test class MarkdownTextInputTest : RobolectricTest() { + @Test + fun `pressing enter on a physical keyboard sends the message`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + val onSendMessage = EnsureCalledOnce() + setMarkdownTextInput(state = state, onSendMessage = onSendMessage) + val editor = activity!!.findEditor() + editor.pressEnter() + awaitIdle() + onSendMessage.assertSuccess() + assertThat(editor.editableText.toString()).isEqualTo("Hello") + } + + @Test + fun `pressing shift enter on a physical keyboard inserts a new line`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + var sendCount = 0 + setMarkdownTextInput(state = state, onSendMessage = { sendCount++ }) + val editor = activity!!.findEditor() + editor.setSelection(editor.editableText.length) + editor.pressEnter(metaState = KeyEvent.META_SHIFT_ON) + awaitIdle() + assertThat(sendCount).isEqualTo(0) + assertThat(editor.editableText.toString()).isEqualTo("Hello\n") + } + + @Test + fun `pressing enter on the soft keyboard does not send the message`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + var sendCount = 0 + setMarkdownTextInput(state = state, onSendMessage = { sendCount++ }) + val editor = activity!!.findEditor() + editor.setSelection(editor.editableText.length) + editor.pressEnter(deviceId = KeyCharacterMap.VIRTUAL_KEYBOARD) + awaitIdle() + assertThat(sendCount).isEqualTo(0) + assertThat(editor.editableText.toString()).isEqualTo("Hello\n") + } + @Test fun `when user types onTyping is triggered with value 'true'`() = runAndroidComposeUiTest { val state = aMarkdownTextEditorState(initialFocus = true) @@ -150,6 +191,7 @@ class MarkdownTextInputTest : RobolectricTest() { state: MarkdownTextEditorState = aMarkdownTextEditorState(), onTyping: (Boolean) -> Unit = {}, onSuggestionReceived: (Suggestion?) -> Unit = {}, + onSendMessage: (() -> Unit)? = null, ) { setContent { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) @@ -161,6 +203,27 @@ class MarkdownTextInputTest : RobolectricTest() { onReceiveSuggestion = onSuggestionReceived, richTextEditorStyle = style, onSelectRichContent = null, + onSendMessage = onSendMessage, + ) + } + } + + private fun EditText.pressEnter( + metaState: Int = 0, + deviceId: Int = PHYSICAL_KEYBOARD_DEVICE_ID, + ) { + listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP).forEach { action -> + dispatchKeyEvent( + KeyEvent( + 0L, + 0L, + action, + KeyEvent.KEYCODE_ENTER, + 0, + metaState, + deviceId, + 0, + ) ) } } @@ -168,4 +231,9 @@ class MarkdownTextInputTest : RobolectricTest() { private fun ComponentActivity.findEditor(): EditText { return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) } + + private companion object { + // Any device id other than KeyCharacterMap.VIRTUAL_KEYBOARD is a real keyboard. + const val PHYSICAL_KEYBOARD_DEVICE_ID = 1 + } } From 4b5429395d588c9b070967be07980811453a281b Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:37:18 +0300 Subject: [PATCH 2/2] Drop the comments from the Enter key handling Review feedback: the callback name and the device-id check read on their own, and the test names carry the soft versus physical keyboard rule. --- .../textcomposer/components/markdown/MarkdownEditText.kt | 6 ------ .../textcomposer/components/markdown/MarkdownTextInput.kt | 1 - .../impl/components/markdown/MarkdownTextInputTest.kt | 1 - 3 files changed, 8 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt index b8d173aaa8..8cc06c77c3 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -19,10 +19,6 @@ internal class MarkdownEditText( ) : AppCompatEditText(context) { var onSelectionChangeListener: ((Int, Int) -> Unit)? = null - /** - * Invoked when Enter is pressed on a physical keyboard without Shift. Return true to consume the - * key, i.e. to send instead of inserting a newline. - */ var onEnterKeyListener: (() -> Boolean)? = null private var isModifyingText = false @@ -30,13 +26,11 @@ internal class MarkdownEditText( override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_ENTER && !event.isShiftPressed && - // A soft keyboard must keep inserting a newline, only a real one sends. event.deviceId != KeyCharacterMap.VIRTUAL_KEYBOARD ) { val listener = onEnterKeyListener if (listener != null) { return when (event.action) { - // Consume the release too, so no newline leaks out on key repeat. KeyEvent.ACTION_UP -> true KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) listener() else true else -> super.dispatchKeyEvent(event) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index adb026d812..8c6f30eed1 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -133,7 +133,6 @@ fun MarkdownTextInput( update = { editText -> editText.contentDescription = placeholder editText.applyStyleInCompose(richTextEditorStyle) - // Set here rather than in the factory, which runs once and would capture a stale lambda. editText.onEnterKeyListener = onSendMessage?.let { send -> { send() diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index 49206775ac..15687cb4c9 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -233,7 +233,6 @@ class MarkdownTextInputTest : RobolectricTest() { } private companion object { - // Any device id other than KeyCharacterMap.VIRTUAL_KEYBOARD is a real keyboard. const val PHYSICAL_KEYBOARD_DEVICE_ID = 1 } }