From 0b0e1630034d30123fbf206b49618ac43a75c11c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 21:18:05 -0700 Subject: [PATCH 1/4] fix(mobile): restore Expo 58 native patches and Android keyboard parity Port the iOS permission and notification race patches to Expo SDK 58 and register them, fix the macOS notification regression test for 58, and make the Android composer and keyboard commands match iOS. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../T3ComposerEditorModule.kt | 6 + .../t3composereditor/T3ComposerEditorView.kt | 43 +++++++ .../t3composereditor/ComposerSubmitTest.kt | 83 ++++++++++++ .../T3KeyboardCommandsModule.kt | 80 ++++++++++-- .../NotificationCenterManagerRegression.swift | 21 +++- .../notification-center-manager.test.ts | 12 ++ .../settings/SettingsKeyboardRouteScreen.tsx | 5 +- apps/mobile/src/lib/composerEnterBehavior.ts | 3 +- .../src/native/T3ComposerEditor.native.tsx | 10 ++ .../src/native/T3ComposerEditor.types.ts | 9 +- docs/internals/mobile-development.md | 15 ++- docs/user/keybindings.md | 5 +- ....patch => expo-modules-core@58.0.11.patch} | 0 patches/expo-notifications@57.0.15.patch | 119 ------------------ patches/expo-notifications@58.0.11.patch | 63 ++++++++++ pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 4 + 17 files changed, 333 insertions(+), 155 deletions(-) create mode 100644 apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt rename patches/{expo-modules-core@57.0.14.patch => expo-modules-core@58.0.11.patch} (100%) delete mode 100644 patches/expo-notifications@57.0.15.patch create mode 100644 patches/expo-notifications@58.0.11.patch 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 1703a7670202..b5f3c43488d5 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 @@ -62,6 +62,8 @@ internal object T3ComposerClipboard { } class T3ComposerEditorModule : Module() { + // A flat list of the view's props and events. + @Suppress("LongMethod") override fun definition() = ModuleDefinition { Name("T3ComposerEditor") @@ -104,6 +106,9 @@ class T3ComposerEditorModule : Module() { Prop("readOnly") { view: T3ComposerEditorView, readOnly: Boolean -> view.setReadOnly(readOnly) } + Prop("enterBehavior") { view: T3ComposerEditorView, behavior: String -> + view.setEnterBehavior(behavior) + } Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean -> view.setScrollEnabled(scrollEnabled) } @@ -128,6 +133,7 @@ class T3ComposerEditorModule : Module() { "onComposerSelectionChange", "onComposerFocus", "onComposerBlur", + "onComposerSubmit", "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", 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 35d796b7876f..b30bbcb4ffeb 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 @@ -18,6 +18,7 @@ import android.text.style.ReplacementSpan import android.util.TypedValue import android.view.Gravity import android.view.GestureDetector +import android.view.KeyCharacterMap import android.view.MotionEvent import android.view.KeyEvent import android.view.ViewGroup @@ -47,6 +48,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 onComposerContextPress by EventDispatcher() private val onComposerPasteContext by EventDispatcher() @@ -143,6 +145,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ), ) } + editor.submitListener = { alternate -> + onComposerSubmit(mapOf("alternate" to alternate)) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -310,6 +315,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.isCursorVisible = editor.isEnabled && !readOnly } + fun setEnterBehavior(behavior: String) { + editor.returnSends = behavior != "newline" + } + fun setScrollEnabled(scrollEnabled: Boolean) { editor.isVerticalScrollBarEnabled = scrollEnabled } @@ -586,10 +595,16 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { var pasteImagesListener: ((List) -> Unit)? = null var pasteContextListener: ((Map) -> Unit)? = null var pasteTextListener: ((String, Int, Int) -> Unit)? = null + + /** Called for a hardware-keyboard send chord. The argument is true for the alternate send. */ + var submitListener: ((Boolean) -> Unit)? = null var textPasteThresholdBytes = 0 var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" + /** Whether a hardware Return sends (`enterBehavior` "send") or inserts a newline. */ + var returnSends = true + /** * Placeholder shown while the draft is empty. An editable TextView never ellipsizes its hint, * so a long placeholder wraps once a wide system font or a large font scale (Samsung defaults) @@ -642,7 +657,35 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { return spans.isNotEmpty() } + /** + * Maps a hardware Return chord to a send, like the iOS composer. The plainer chord does the + * configured behavior and the Ctrl chord does the other one: with "send", Return sends, + * Ctrl-Return sends the alternate way and Shift-Return inserts a newline; with "newline", + * Ctrl-Return sends and Ctrl-Shift-Return sends the alternate way. Returns whether the send is + * the alternate one, or null to insert a newline. Soft keyboards always insert a newline. + */ + private fun submitChord(keyCode: Int, event: KeyEvent): Boolean? { + val isReturn = keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + val fromSoftKeyboard = + event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD || + (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0 + if (!isReturn || fromSoftKeyboard || readOnly) return null + return when { + event.hasNoModifiers() -> if (returnSends) false else null + event.hasModifiers(KeyEvent.META_CTRL_ON) -> returnSends + event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) -> + if (returnSends) null else true + else -> null + } + } + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + val alternate = submitChord(keyCode, event) + if (alternate != null) { + // A held Return repeats. Send once, and drop the repeats instead of typing newlines. + if (event.repeatCount == 0) submitListener?.invoke(alternate) + return true + } val handled = when (keyCode) { KeyEvent.KEYCODE_DEL -> deleteChip(true) KeyEvent.KEYCODE_FORWARD_DEL -> deleteChip(false) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt new file mode 100644 index 000000000000..f2fa78d2c9d1 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt @@ -0,0 +1,83 @@ +package expo.modules.t3composereditor + +import android.text.InputType +import android.view.KeyCharacterMap +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class ComposerSubmitTest { + private val sends = mutableListOf() + private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + submitListener = { alternate -> sends += alternate } + } + + private fun pressReturn( + metaState: Int = 0, + deviceId: Int = HARDWARE_KEYBOARD, + repeatCount: Int = 0 + ) { + val event = KeyEvent( + 0, + 0, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_ENTER, + repeatCount, + metaState, + deviceId, + 0, + ) + editor.onKeyDown(KeyEvent.KEYCODE_ENTER, event) + } + + @Test + fun returnSendsAndCtrlReturnSendsTheAlternateWay() { + pressReturn() + pressReturn(KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + + assertEquals(listOf(false, true), sends) + assertEquals("", editor.text.toString()) + } + + @Test + fun shiftReturnAndSoftKeyboardReturnInsertNewlines() { + pressReturn(KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) + pressReturn(deviceId = KeyCharacterMap.VIRTUAL_KEYBOARD) + + assertEquals(emptyList(), sends) + assertEquals("\n\n", editor.text.toString()) + } + + @Test + fun newlineBehaviorSendsOnlyWithCtrl() { + editor.returnSends = false + + pressReturn() + pressReturn(KeyEvent.META_CTRL_ON) + pressReturn(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) + + assertEquals(listOf(false, true), sends) + assertEquals("\n", editor.text.toString()) + } + + @Test + fun heldReturnSendsOnce() { + pressReturn() + pressReturn(repeatCount = 1) + pressReturn(repeatCount = 2) + + assertEquals(listOf(false), sends) + assertEquals("", editor.text.toString()) + } + + private companion object { + const val HARDWARE_KEYBOARD = 1 + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt index 68608d9eb3f9..3453ef79e421 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -1,6 +1,7 @@ package expo.modules.t3nativecontrols import android.content.Context +import android.os.Build import android.view.KeyEvent import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module @@ -8,6 +9,9 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView +// Android's tablet breakpoint. Large screens get the iPad-only commands. +private const val LARGE_SCREEN_MIN_WIDTH_DP = 600 + class T3KeyboardCommandsModule : Module() { override fun definition() = ModuleDefinition { Name("T3KeyboardCommands") @@ -28,19 +32,71 @@ class T3KeyboardCommandsView( private val onCommand by EventDispatcher() var enabledCommands = emptySet() + init { + // A key reaches dispatchKeyEvent only while focus is inside this view. With nothing focused, + // Android offers it to unhandled-key listeners instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + addOnUnhandledKeyEventListener { _, event -> + val command = enabledCommandFor(event) + if (command != null) onCommand(mapOf("command" to command)) + command != null + } + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val copiesThreadReference = - event.action == KeyEvent.ACTION_DOWN && - event.repeatCount == 0 && - event.keyCode == KeyEvent.KEYCODE_C && - event.isCtrlPressed && - event.isShiftPressed && - !event.isAltPressed && - enabledCommands.contains("copyThreadReference") - if (copiesThreadReference) { - onCommand(mapOf("command" to "copyThreadReference")) - return true + val command = enabledCommandFor(event) ?: return super.dispatchKeyEvent(event) + // Unmodified keys (palette arrows and Escape) win over the focused search field. Ctrl chords + // go to the focused view first, so the terminal keeps the control keys it sends to the shell. + val focusedViewHandled = !event.hasNoModifiers() && super.dispatchKeyEvent(event) + if (!focusedViewHandled) onCommand(mapOf("command" to command)) + return true + } + + private fun enabledCommandFor(event: KeyEvent): String? { + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return null + val largeScreen = resources.configuration.smallestScreenWidthDp >= LARGE_SCREEN_MIN_WIDTH_DP + return hardwareKeyboardCommand(event, largeScreen)?.takeIf(enabledCommands::contains) + } +} + +private val UNMODIFIED_COMMANDS = mapOf( + KeyEvent.KEYCODE_DPAD_DOWN to "paletteNext", + KeyEvent.KEYCODE_DPAD_UP to "palettePrevious", + KeyEvent.KEYCODE_ESCAPE to "paletteDismiss", +) + +private val CTRL_COMMANDS = mapOf( + KeyEvent.KEYCODE_N to "newTask", + KeyEvent.KEYCODE_F to "focusSearch", + KeyEvent.KEYCODE_LEFT_BRACKET to "back", + KeyEvent.KEYCODE_BACKSLASH to "toggleSidebar", +) + +private val CTRL_SHIFT_COMMANDS = mapOf( + KeyEvent.KEYCODE_F to "files", + KeyEvent.KEYCODE_T to "terminal", + KeyEvent.KEYCODE_R to "review", + KeyEvent.KEYCODE_C to "copyThreadReference", +) + +/** + * The command for a hardware-keyboard chord. Matches the iOS module with Ctrl in place of + * Command. Like iPad, large screens get the command palette and thread jumps; on phones Ctrl-K + * focuses search. + */ +private fun hardwareKeyboardCommand(event: KeyEvent, largeScreen: Boolean): String? { + val keyCode = event.keyCode + return when { + event.hasNoModifiers() -> UNMODIFIED_COMMANDS[keyCode] + event.hasModifiers(KeyEvent.META_CTRL_ON) -> when { + keyCode == KeyEvent.KEYCODE_K -> if (largeScreen) "commandPalette" else "focusSearch" + largeScreen && keyCode in KeyEvent.KEYCODE_1..KeyEvent.KEYCODE_9 -> + "thread.jump.${keyCode - KeyEvent.KEYCODE_0}" + else -> CTRL_COMMANDS[keyCode] } - return super.dispatchKeyEvent(event) + event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) -> + CTRL_SHIFT_COMMANDS[keyCode] + else -> null } } diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift index 6d13972d5742..1dc014bc3bf7 100644 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift @@ -6,9 +6,9 @@ public enum UIBackgroundFetchResult { case noData } -public struct UNNotificationPresentationOptions: OptionSet { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } +// A class, because the delegate protocol below is @objc. The manager only uses `[]`. +public final class UNNotificationPresentationOptions: NSObject, ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Int...) { super.init() } } public final class UNNotification: NSObject {} @@ -18,7 +18,20 @@ public final class UNNotificationResponse: NSObject { init(_ identifier: String) { self.identifier = identifier } } -public protocol UNUserNotificationCenterDelegate: AnyObject {} +// The manager forwards calls to a chained delegate through these optional methods. +@objc public protocol UNUserNotificationCenterDelegate: NSObjectProtocol { + @objc optional func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) + @objc optional func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) + @objc optional func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) +} public final class UNUserNotificationCenter: NSObject { private static let instance = UNUserNotificationCenter() diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts index e7a059496af6..db413ebfe1bd 100644 --- a/apps/mobile/scripts/notification-center-manager.test.ts +++ b/apps/mobile/scripts/notification-center-manager.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -28,12 +29,23 @@ describe.skipIf(NodeOS.platform() !== "darwin")( manager, source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), ); + // The manager guards its registry with ExpoModulesCore's Mutex, so compile that file too. + const require = NodeModule.createRequire(import.meta.url); + const mutex = NodePath.join( + NodePath.dirname( + NodeModule.createRequire(require.resolve("expo/package.json")).resolve( + "expo-modules-core/package.json", + ), + ), + "ios/Utilities/Mutex.swift", + ); NodeChildProcess.execFileSync( "swiftc", [ "-swift-version", "5", "-sanitize=thread", + mutex, manager, NodeURL.fileURLToPath( new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), diff --git a/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx index f0cb34ce9d7e..a4e393e0965b 100644 --- a/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx @@ -15,6 +15,9 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; +// Android keyboards send with Ctrl where iPad keyboards use Command. +const SEND_MODIFIER = Platform.OS === "android" ? "Ctrl" : "Command"; + const ENTER_BEHAVIOR_OPTIONS: ReadonlyArray<{ readonly behavior: ComposerEnterBehavior; readonly label: string; @@ -28,7 +31,7 @@ const ENTER_BEHAVIOR_OPTIONS: ReadonlyArray<{ { behavior: "newline", label: "Insert new line", - description: "Return inserts a new line. Command-Return sends the message.", + description: `Return inserts a new line. ${SEND_MODIFIER}-Return sends the message.`, }, ]; diff --git a/apps/mobile/src/lib/composerEnterBehavior.ts b/apps/mobile/src/lib/composerEnterBehavior.ts index 1698b0ff1bd2..c5201ed8c084 100644 --- a/apps/mobile/src/lib/composerEnterBehavior.ts +++ b/apps/mobile/src/lib/composerEnterBehavior.ts @@ -1,8 +1,7 @@ /** * What the Return key does in the composer on a hardware keyboard. `send` * submits the draft and Shift-Return inserts a newline; `newline` inserts a - * newline and Command-Return submits. Applies on iOS only — Android's composer - * has no hardware Return handling. + * newline and Command-Return (Ctrl-Return on Android) submits. */ export type ComposerEnterBehavior = "send" | "newline"; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9391a3814c39..b4e869216f52 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -2,6 +2,7 @@ import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; +import { DEFAULT_COMPOSER_ENTER_BEHAVIOR } from "../lib/composerEnterBehavior"; import { requireNativeView } from "expo"; import { TextInputWrapper } from "expo-paste-input"; import { @@ -90,6 +91,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly autoFocus: boolean; readonly autoCorrect: boolean; readonly spellCheck: boolean; + readonly enterBehavior: string; readonly onComposerChange: (event: NativeEditorEvent) => void; readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; @@ -104,6 +106,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerSubmit?: (event: NativeSyntheticEvent<{ alternate: boolean }>) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -129,6 +132,7 @@ export function ComposerEditor({ onPasteText, onFocus, onBlur, + onSubmit, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -298,6 +302,7 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR} textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={{ flex: 1, minHeight: 0 }} @@ -366,6 +371,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerSubmit={ + onSubmit === undefined + ? undefined + : (event) => onSubmit(event.nativeEvent.alternate === true) + } /> ); diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index c6a9bfc11fa5..cc70c11f2afa 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -65,10 +65,7 @@ export interface ComposerEditorProps { readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; - /** - * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no - * hardware Return handling. - */ + /** Hardware-keyboard Return behavior. Soft keyboards always insert a newline. */ readonly enterBehavior?: ComposerEnterBehavior; /** Shortcut-HUD title for the primary send chord. iOS only. */ readonly submitTitle?: string; @@ -76,8 +73,8 @@ export interface ComposerEditorProps { readonly alternateSubmitTitle?: string; /** * Hardware keyboard submission. `alternate` is true for the Command-modified - * chord, which sends using the opposite of the configured follow-up - * behavior. Always false on Android, which has no hardware Return handling. + * chord (Ctrl on Android), which sends using the opposite of the configured + * follow-up behavior. */ readonly onSubmit?: (alternate: boolean) => void; } diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index 9f0cb9d2e2fb..8dc18e3e504c 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -24,12 +24,15 @@ would lose new classes; invalidating every consumer for unchanged output makes a ordinary component edit refresh the whole app. The fingerprint is recorded only after initialization succeeds. -The [expo-notifications patch](../../patches/expo-notifications@57.0.15.patch) protects -`NotificationCenterManager`'s delegates and pending responses with a lock. React runtimes can -register and remove delegates concurrently during reloads or scene startup. Delivery snapshots -delegates under the lock and invokes them after releasing it. Pending-response replay removes -only the responses in its snapshot, preserving responses received during callbacks. Changes to -this native patch require reinstalling dependencies and rebuilding the iOS app. +The [expo-notifications patch](../../patches/expo-notifications@58.0.11.patch) closes two +races in `NotificationCenterManager`. React runtimes can register and remove delegates +concurrently during reloads or scene startup. Delivery queues a response before it snapshots +the delegates, so a delegate that registers during delivery still receives it. Pending-response +replay removes only the responses in its snapshot, preserving responses received during +callbacks. The [expo-modules-core patch](../../patches/expo-modules-core@58.0.11.patch) locks +the permission requester registry for the same reason. Changes to these native patches require +reinstalling dependencies and rebuilding the iOS app. pnpm keys each patch to one exact version, +so an Expo upgrade must port it and update `patchedDependencies`, or the fix silently drops. The native modules under `apps/mobile/modules/` are `file:` dependencies, and pnpm copies those into its virtual store instead of linking them. Metro bundles the copy, so an edit to a module's TypeScript is invisible to a running dev client until diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 43bd23333f66..a123edd3f4fe 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -43,7 +43,7 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID” or “Copy Number”. They copy the selected PR and leave terminal input alone. -## iPad +## iPad and Android With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine displayed threads. The shortcuts follow the current list filters and order. @@ -56,6 +56,9 @@ In the composer, Return sends and `Shift+Return` inserts a new line. `Cmd+Return also sends. To make Return insert a new line instead, change the Return key behavior in Settings → Keyboard. +On Android, use `Ctrl` in place of `Cmd`. Thread shortcuts and the command palette +need a tablet-sized screen; on a phone, `Ctrl+K` opens search. + ## Edit the configuration file Keybindings live on the environment's machine, in diff --git a/patches/expo-modules-core@57.0.14.patch b/patches/expo-modules-core@58.0.11.patch similarity index 100% rename from patches/expo-modules-core@57.0.14.patch rename to patches/expo-modules-core@58.0.11.patch diff --git a/patches/expo-notifications@57.0.15.patch b/patches/expo-notifications@57.0.15.patch deleted file mode 100644 index 93ad9e704dd5..000000000000 --- a/patches/expo-notifications@57.0.15.patch +++ /dev/null @@ -1,119 +0,0 @@ -diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift ---- a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift -+++ b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift -@@ -42,8 +42,10 @@ - @objc - public static let shared = NotificationCenterManager() - -- var delegates: [NotificationDelegate] = [] -- var pendingResponses: [UNNotificationResponse] = [] -+ // Module creation and destruction can overlap across React runtimes. -+ private let stateLock = NSLock() -+ private var delegates: [NotificationDelegate] = [] -+ private var pendingResponses: [UNNotificationResponse] = [] - let userNotificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current() - - private override init() { -@@ -62,32 +64,49 @@ - } - - public func addDelegate(_ delegate: NotificationDelegate) { -- delegates.append(delegate) -+ let responses = stateLock.withLock { -+ delegates.append(delegate) -+ return pendingResponses -+ } - var handled = false -- for pendingResponse in pendingResponses { -+ for pendingResponse in responses { - handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled - } - if handled { -- pendingResponses.removeAll() -+ removePendingResponses(responses) - } - } - - public func removeDelegate(_ delegate: AnyObject) { -- if let index = delegates.firstIndex(where: { $0 === delegate }) { -- delegates.remove(at: index) -+ stateLock.withLock { -+ if let index = delegates.firstIndex(where: { $0 === delegate }) { -+ delegates.remove(at: index) -+ } -+ } -+ } -+ -+ private func snapshotDelegates() -> [NotificationDelegate] { -+ stateLock.withLock { delegates } -+ } -+ -+ private func removePendingResponses(_ responses: [UNNotificationResponse]) { -+ let identifiers = Set(responses.map(ObjectIdentifier.init)) -+ stateLock.withLock { -+ // Keep responses received while delegate callbacks were running. -+ pendingResponses.removeAll { identifiers.contains(ObjectIdentifier($0)) } - } - } - - // MARK: - Called by PushTokenAppDelegateSubscriber - - public func didFailRegistration(_ error: any Error) { -- for delegate in delegates { -+ for delegate in snapshotDelegates() { - delegate.didFailRegistration(error) - } - } - - public func didRegister(_ deviceToken: String) { -- for delegate in delegates { -+ for delegate in snapshotDelegates() { - delegate.didRegister(deviceToken) - } - } -@@ -100,7 +119,7 @@ - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - var handled = false -- for delegate in delegates { -+ for delegate in snapshotDelegates() { - handled = delegate.willPresent(notification, completionHandler: completionHandler) || handled - } - if !handled { -@@ -113,18 +132,23 @@ - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { -+ let currentDelegates = stateLock.withLock { -+ // A delegate registering during delivery must see this response too. -+ pendingResponses.append(response) -+ return delegates -+ } - var handled = false -- for delegate in delegates { -+ for delegate in currentDelegates { - handled = delegate.didReceive(response, completionHandler: completionHandler) || handled - } -- if !handled { -- pendingResponses.append(response) -+ if handled { -+ removePendingResponses([response]) - } - completionHandler() - } - - public func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { -- for delegate in delegates { -+ for delegate in snapshotDelegates() { - delegate.openSettings(notification) - } - } -@@ -132,7 +156,7 @@ - // MARK: - Called from NotificationsAppDelegateSubscriber - public func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - var handled = false -- for delegate in delegates { -+ for delegate in snapshotDelegates() { - handled = delegate.didReceive(userInfo, completionHandler: completionHandler) || handled - } - if !handled { diff --git a/patches/expo-notifications@58.0.11.patch b/patches/expo-notifications@58.0.11.patch new file mode 100644 index 000000000000..a1dcae4787db --- /dev/null +++ b/patches/expo-notifications@58.0.11.patch @@ -0,0 +1,63 @@ +diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift +--- a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift ++++ b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift +@@ -82,12 +82,24 @@ internal final class NotificationDelegateRegistry { + state.withLock { $0.delegates.removeAll { $0 === delegate } } + } + +- func appendPendingResponse(_ response: UNNotificationResponse) { +- state.withLock { $0.pendingResponses.append(response) } ++ /** ++ Queues the response before it is delivered, and returns the delegates to deliver it to. A ++ delegate that registers during delivery then finds the response in `add`. ++ */ ++ func appendPendingResponse(_ response: UNNotificationResponse) -> [NotificationDelegate] { ++ state.withLock { state in ++ state.pendingResponses.append(response) ++ return state.delegates ++ } + } + +- func removeAllPendingResponses() { +- state.withLock { $0.pendingResponses.removeAll() } ++ /** ++ Removes only the given responses, so that responses received during delegate callbacks stay ++ queued. ++ */ ++ func removePendingResponses(_ responses: [UNNotificationResponse]) { ++ let identifiers = Set(responses.map(ObjectIdentifier.init)) ++ state.withLock { $0.pendingResponses.removeAll { identifiers.contains(ObjectIdentifier($0)) } } + } + } + +@@ -162,12 +174,13 @@ public class NotificationCenterManager: NSObject, + } + + public func addDelegate(_ delegate: NotificationDelegate) { ++ let responses = registry.add(delegate) + var handled = false +- for pendingResponse in registry.add(delegate) { ++ for pendingResponse in responses { + handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled + } + if handled { +- registry.removeAllPendingResponses() ++ registry.removePendingResponses(responses) + } + } + +@@ -228,11 +241,11 @@ public class NotificationCenterManager: NSObject, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + var handled = false +- for delegate in delegates { ++ for delegate in registry.appendPendingResponse(response) { + handled = delegate.didReceive(response, completionHandler: completionHandler) || handled + } +- if !handled { +- registry.appendPendingResponse(response) ++ if handled { ++ registry.removePendingResponses([response]) + } + chainedDelegate?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) + completionHandler() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 092af1eaeeb7..1bea1feccebf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,8 @@ patchedDependencies: expo-audio@58.0.4: c20cb927d6848c53e7bf6ec14fe12f19daae9210165d2dbaf4e0f97ce31e8742 expo-blur@58.0.3: d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18 expo-glass-effect@58.0.3: f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57 + expo-modules-core@58.0.11: 6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e + expo-notifications@58.0.11: 73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 @@ -398,7 +400,7 @@ importers: version: 58.0.3(expo@58.0.2)(react@19.3.0) expo-notifications: specifier: ~58.0.11 - version: 58.0.11(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) + version: 58.0.11(patch_hash=73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) expo-paste-input: specifier: ^0.1.15 version: 0.1.15(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -17396,7 +17398,7 @@ snapshots: - supports-color - typescript - expo-modules-core@58.0.11(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-modules-core@58.0.11(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: expo-modules-jsi: 58.0.7(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-modules-macros: 0.14.0 @@ -17417,7 +17419,7 @@ snapshots: expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) react: 19.3.0 - expo-notifications@58.0.11(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): + expo-notifications@58.0.11(patch_hash=73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.12.2(typescript@7.0.2) abort-controller: 3.0.0 @@ -17568,7 +17570,7 @@ snapshots: expo-font: 58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) expo-keep-awake: 58.0.3(expo@58.0.2)(react@19.3.0) expo-modules-autolinking: 58.0.7(typescript@7.0.2) - expo-modules-core: 58.0.11(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-modules-core: 58.0.11(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) pretty-format: 29.7.0 react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c0ef065701e..edb131ade908 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -243,6 +243,10 @@ patchedDependencies: expo-glass-effect@58.0.3: patches/expo-glass-effect@58.0.3.patch expo-blur@58.0.3: patches/expo-blur@58.0.3.patch expo-audio@58.0.4: patches/expo-audio@58.0.4.patch + # Lock the permission requester registry. Modules register requesters while other threads read them. + expo-modules-core@58.0.11: patches/expo-modules-core@58.0.11.patch + # Queue a notification response before delivery, and remove only the replayed responses, so a tap is not lost. + expo-notifications@58.0.11: patches/expo-notifications@58.0.11.patch "@clerk/expo@4.6.8": patches/@clerk__expo@4.6.8.patch "@effect/vitest@4.0.0-rc.115": patches/@effect__vitest@4.0.0-rc.115.patch "@expo/metro-config@58.0.6": patches/@expo__metro-config@58.0.6.patch From 0c6ce85e980c4efbfec6b52368e96654bd7aabb5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 21:47:47 -0700 Subject: [PATCH 2/4] docs(mobile): note the API 28 limit for unfocused keyboard shortcuts Co-Authored-By: Claude Opus 5.5 (1M context) --- .../expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt index 3453ef79e421..0338e4497486 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -34,7 +34,8 @@ class T3KeyboardCommandsView( init { // A key reaches dispatchKeyEvent only while focus is inside this view. With nothing focused, - // Android offers it to unhandled-key listeners instead. + // Android offers it to unhandled-key listeners instead. That listener needs API 28, so on + // API 24-27 shortcuts work only while something inside this view has focus. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { addOnUnhandledKeyEventListener { _, event -> val command = enabledCommandFor(event) From 7eee58a08d9ef8f9f6a0c98f4c2daa161ada6203 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:37:48 -0700 Subject: [PATCH 3/4] fix(mobile): ship patched core and preserve unhandled input --- .../T3ComposerEditorModule.kt | 3 ++ .../t3composereditor/T3ComposerEditorView.kt | 11 ++++++- .../t3composereditor/ComposerSubmitTest.kt | 21 ++++++++++++ apps/mobile/package.json | 1 + .../NotificationCenterManagerRegression.swift | 16 ++++++++++ .../notification-center-manager.test.ts | 1 + .../src/native/T3ComposerEditor.native.tsx | 2 ++ docs/internals/mobile-development.md | 12 ++++--- patches/expo-notifications@58.0.11.patch | 32 +++++++++++-------- pnpm-lock.yaml | 6 ++-- 10 files changed, 82 insertions(+), 23 deletions(-) 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 b5f3c43488d5..e86f45b63439 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 @@ -109,6 +109,9 @@ class T3ComposerEditorModule : Module() { Prop("enterBehavior") { view: T3ComposerEditorView, behavior: String -> view.setEnterBehavior(behavior) } + Prop("submitEnabled") { view: T3ComposerEditorView, enabled: Boolean -> + view.setSubmitEnabled(enabled) + } Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean -> view.setScrollEnabled(scrollEnabled) } 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 b30bbcb4ffeb..9d310956f107 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 @@ -319,6 +319,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.returnSends = behavior != "newline" } + fun setSubmitEnabled(enabled: Boolean) { + editor.submitEnabled = enabled + } + fun setScrollEnabled(scrollEnabled: Boolean) { editor.isVerticalScrollBarEnabled = scrollEnabled } @@ -605,6 +609,9 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { /** Whether a hardware Return sends (`enterBehavior` "send") or inserts a newline. */ var returnSends = true + /** The JS wrapper enables submission only when it has an onSubmit handler. */ + var submitEnabled = false + /** * Placeholder shown while the draft is empty. An editable TextView never ellipsizes its hint, * so a long placeholder wraps once a wide system font or a large font scale (Samsung defaults) @@ -669,7 +676,9 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { val fromSoftKeyboard = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD || (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0 - if (!isReturn || fromSoftKeyboard || readOnly) return null + if (!isReturn || fromSoftKeyboard || readOnly || !submitEnabled || submitListener == null) { + return null + } return when { event.hasNoModifiers() -> if (returnSends) false else null event.hasModifiers(KeyEvent.META_CTRL_ON) -> returnSends diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt index f2fa78d2c9d1..3745a7026963 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerSubmitTest.kt @@ -16,6 +16,7 @@ class ComposerSubmitTest { private val sends = mutableListOf() private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + submitEnabled = true submitListener = { alternate -> sends += alternate } } @@ -67,6 +68,26 @@ class ComposerSubmitTest { assertEquals("\n", editor.text.toString()) } + @Test + fun returnInsertsANewlineWhenSubmissionIsUnavailable() { + editor.submitEnabled = false + + pressReturn() + + assertEquals(emptyList(), sends) + assertEquals("\n", editor.text.toString()) + } + + @Test + fun returnInsertsANewlineWithoutASubmitListener() { + editor.submitListener = null + + pressReturn() + + assertEquals(emptyList(), sends) + assertEquals("\n", editor.text.toString()) + } + @Test fun heldReturnSendsOnce() { pressReturn() diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d1bd5c810894..f02de19bf142 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -146,6 +146,7 @@ }, "autolinking": { "buildFromSource": [ + "expo-modules-core", "expo-notifications", "react-native-screens", "react-native-reanimated", diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift index 1dc014bc3bf7..b5caad2e996c 100644 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift @@ -182,6 +182,21 @@ private func concurrentRegistrations() throws { try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") } +private func partiallyHandledReplay() throws { + let manager = NotificationCenterManager.shared + deliver("handled") + deliver("unhandled") + let original = TestDelegate() + original.onResponse = { $0.identifier == "handled" } + manager.addDelegate(original) + let receiver = TestDelegate() + receiver.onResponse = { _ in true } + manager.addDelegate(receiver) + try require(receiver.events == ["unhandled"], "Replay must retain each response that the delegate did not handle") + manager.removeDelegate(original) + manager.removeDelegate(receiver) +} + @main private enum RegressionTests { static func main() { @@ -190,6 +205,7 @@ private enum RegressionTests { case "reentrant": try reentrantCallbacks() case "handoff": try registrationDuringDelivery() case "pending": try responseDuringReplay() + case "partial": try partiallyHandledReplay() case "concurrent": try concurrentRegistrations() default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) } diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts index db413ebfe1bd..aad62104b677 100644 --- a/apps/mobile/scripts/notification-center-manager.test.ts +++ b/apps/mobile/scripts/notification-center-manager.test.ts @@ -65,6 +65,7 @@ describe.skipIf(NodeOS.platform() !== "darwin")( ["reentrant", "allows callbacks to replace delegates without deadlocking"], ["handoff", "delivers responses to delegates registering during delivery"], ["pending", "retains new responses received while replaying pending responses"], + ["partial", "retains responses that a replaying delegate did not handle"], ["concurrent", "registers, removes, and broadcasts concurrently without data races"], ])("%s: %s", (name) => { const output = NodeChildProcess.execFileSync(executable, [name], { diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index b4e869216f52..d55802ac8f16 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -92,6 +92,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly autoCorrect: boolean; readonly spellCheck: boolean; readonly enterBehavior: string; + readonly submitEnabled: boolean; readonly onComposerChange: (event: NativeEditorEvent) => void; readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; @@ -303,6 +304,7 @@ export function ComposerEditor({ autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR} + submitEnabled={onSubmit !== undefined} textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={{ flex: 1, minHeight: 0 }} diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index 8dc18e3e504c..c8855b05c051 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -28,11 +28,13 @@ The [expo-notifications patch](../../patches/expo-notifications@58.0.11.patch) c races in `NotificationCenterManager`. React runtimes can register and remove delegates concurrently during reloads or scene startup. Delivery queues a response before it snapshots the delegates, so a delegate that registers during delivery still receives it. Pending-response -replay removes only the responses in its snapshot, preserving responses received during -callbacks. The [expo-modules-core patch](../../patches/expo-modules-core@58.0.11.patch) locks -the permission requester registry for the same reason. Changes to these native patches require -reinstalling dependencies and rebuilding the iOS app. pnpm keys each patch to one exact version, -so an Expo upgrade must port it and update `patchedDependencies`, or the fix silently drops. +replay removes only handled responses from its snapshot, preserving unhandled responses and +responses received during callbacks. The [expo-modules-core patch](../../patches/expo-modules-core@58.0.11.patch) locks +the permission requester registry for the same reason. Both packages must stay in Expo's +`buildFromSource` list or their precompiled frameworks bypass the patched source. Changes to these +native patches require reinstalling dependencies and rebuilding the iOS app. pnpm keys each patch +to one exact version, so an Expo upgrade must port it and update `patchedDependencies`, or the fix +silently drops. The native modules under `apps/mobile/modules/` are `file:` dependencies, and pnpm copies those into its virtual store instead of linking them. Metro bundles the copy, so an edit to a module's TypeScript is invisible to a running dev client until diff --git a/patches/expo-notifications@58.0.11.patch b/patches/expo-notifications@58.0.11.patch index a1dcae4787db..0475812e3bdc 100644 --- a/patches/expo-notifications@58.0.11.patch +++ b/patches/expo-notifications@58.0.11.patch @@ -1,12 +1,16 @@ diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift --- a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift +++ b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift -@@ -82,12 +82,24 @@ internal final class NotificationDelegateRegistry { +@@ -82,12 +82,24 @@ state.withLock { $0.delegates.removeAll { $0 === delegate } } } - func appendPendingResponse(_ response: UNNotificationResponse) { - state.withLock { $0.pendingResponses.append(response) } +- } +- +- func removeAllPendingResponses() { +- state.withLock { $0.pendingResponses.removeAll() } + /** + Queues the response before it is delivered, and returns the delegates to deliver it to. A + delegate that registers during delivery then finds the response in `add`. @@ -16,10 +20,8 @@ diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift + state.pendingResponses.append(response) + return state.delegates + } - } - -- func removeAllPendingResponses() { -- state.withLock { $0.pendingResponses.removeAll() } ++ } ++ + /** + Removes only the given responses, so that responses received during delegate callbacks stay + queued. @@ -30,23 +32,25 @@ diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift } } -@@ -162,12 +174,13 @@ public class NotificationCenterManager: NSObject, +@@ -162,12 +174,11 @@ } public func addDelegate(_ delegate: NotificationDelegate) { -+ let responses = registry.add(delegate) - var handled = false +- var handled = false - for pendingResponse in registry.add(delegate) { -+ for pendingResponse in responses { - handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled - } - if handled { +- handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled +- } +- if handled { - registry.removeAllPendingResponses() -+ registry.removePendingResponses(responses) ++ let handledResponses = registry.add(delegate).filter { response in ++ delegate.didReceive(response, completionHandler: {}) ++ } ++ if !handledResponses.isEmpty { ++ registry.removePendingResponses(handledResponses) } } -@@ -228,11 +241,11 @@ public class NotificationCenterManager: NSObject, +@@ -228,11 +239,11 @@ withCompletionHandler completionHandler: @escaping () -> Void ) { var handled = false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bea1feccebf..31683e09888d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,7 +108,7 @@ patchedDependencies: expo-blur@58.0.3: d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18 expo-glass-effect@58.0.3: f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57 expo-modules-core@58.0.11: 6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e - expo-notifications@58.0.11: 73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70 + expo-notifications@58.0.11: 96980602a8c17de97a04007aa095102c647979b5001f45f71bfad58f5547b8a5 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 @@ -400,7 +400,7 @@ importers: version: 58.0.3(expo@58.0.2)(react@19.3.0) expo-notifications: specifier: ~58.0.11 - version: 58.0.11(patch_hash=73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) + version: 58.0.11(patch_hash=96980602a8c17de97a04007aa095102c647979b5001f45f71bfad58f5547b8a5)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) expo-paste-input: specifier: ^0.1.15 version: 0.1.15(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -17419,7 +17419,7 @@ snapshots: expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) react: 19.3.0 - expo-notifications@58.0.11(patch_hash=73aac7244c4ed389556f394619196572cd00bee78e7332a8d311145ec5e13b70)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): + expo-notifications@58.0.11(patch_hash=96980602a8c17de97a04007aa095102c647979b5001f45f71bfad58f5547b8a5)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.12.2(typescript@7.0.2) abort-controller: 3.0.0 From a2d855c9f602d50da63fea6c07d3ca6c7db36655 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:39:00 -0700 Subject: [PATCH 4/4] refactor(mobile): keep the Return guard simple --- .../java/expo/modules/t3composereditor/T3ComposerEditorView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9d310956f107..3f64274385b4 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 @@ -676,7 +676,8 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { val fromSoftKeyboard = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD || (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0 - if (!isReturn || fromSoftKeyboard || readOnly || !submitEnabled || submitListener == null) { + val canSubmit = submitEnabled && submitListener != null && !readOnly + if (!isReturn || fromSoftKeyboard || !canSubmit) { return null } return when {