mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
+3
-2
@@ -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) }
|
||||
}
|
||||
|
||||
+27
@@ -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()
|
||||
|
||||
+8
@@ -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()) {
|
||||
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user