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
This commit is contained in:
hayaksi1
2026-08-09 04:09:10 +03:00
parent 044737a3a7
commit 1024323661
4 changed files with 106 additions and 2 deletions
@@ -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) }
}
@@ -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()
@@ -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()) {
@@ -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
}
}