From 094a8d253bee5eaebf8384eb3e0288157b36905b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 27 Aug 2026 09:05:57 +0200 Subject: [PATCH] fix(mobile): send on Ctrl/Cmd+Enter from a hardware keyboard on Android On Android the composer had no way to send from a hardware keyboard: plain Enter inserts a newline (correct for touch) and no modifier shortcut was wired, so Bluetooth-keyboard users on tablets and phones were stuck. iOS already sends on Cmd+Enter through an onComposerSubmit event, but the Android native module, view, and JS bridge never implemented it. Wire the same onComposerSubmit path on Android: the native EditText now detects Ctrl/Meta+Enter and emits onComposerSubmit, and the Android JS bridge (T3ComposerEditor.native.tsx) forwards it to the composer's onSubmit, mirroring the iOS variant. Plain Enter still inserts a newline. Done by Claude Opus 4.8 via Claude Code. Co-Authored-By: Claude Opus 4.8 --- .../T3ComposerEditorModule.kt | 1 + .../t3composereditor/T3ComposerEditorView.kt | 25 +++++++++++++++++++ .../src/native/T3ComposerEditor.native.tsx | 3 +++ 3 files changed, 29 insertions(+) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index e11181e81a9f..6b8366b44a5a 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -54,6 +54,7 @@ class T3ComposerEditorModule : Module() { "onComposerSelectionChange", "onComposerFocus", "onComposerBlur", + "onComposerSubmit", "onComposerPasteImages", "onComposerContentSizeChange", ) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 3010b5240997..236765e95ad1 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -13,6 +13,7 @@ import android.text.Spanned import android.text.TextWatcher import android.text.style.ReplacementSpan import android.view.Gravity +import android.view.KeyEvent import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.EditText @@ -31,6 +32,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerSelectionChange by EventDispatcher() private val onComposerFocus by EventDispatcher() private val onComposerBlur by EventDispatcher() + private val onComposerSubmit by EventDispatcher() private val onComposerPasteImages by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false @@ -65,6 +67,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } + // A hardware keyboard has no on-screen Send button to reach for, so + // Ctrl/Meta+Enter is the way to send. Plain Enter still inserts a newline. + editor.submitListener = { + onComposerSubmit(emptyMap()) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -457,12 +464,30 @@ private fun parseTokens(value: String): List = try { private class SelectionAwareEditText(context: Context) : EditText(context) { var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null + var submitListener: (() -> Unit)? = null override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) } + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // Ctrl/Meta+Enter from a hardware keyboard sends; plain Enter falls + // through to the default multiline newline insertion. + val listener = submitListener + if (event != null && listener != null && isSubmitShortcut(keyCode, event)) { + listener.invoke() + return true + } + return super.onKeyDown(keyCode, event) + } + + private fun isSubmitShortcut(keyCode: Int, event: KeyEvent): Boolean { + val isEnter = keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + val hasSubmitModifier = event.isCtrlPressed || event.isMetaPressed + return isEnter && hasSubmitModifier + } + override fun onTextContextMenuItem(id: Int): Boolean { if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index ff177abf1642..520e8217e124 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -74,6 +74,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerSubmit?: () => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -98,6 +99,7 @@ export function ComposerEditor({ onPasteImages, onFocus, onBlur, + onSubmit, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -296,6 +298,7 @@ export function ComposerEditor({ onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerSubmit={onSubmit} /> );