fix(app): iPad hardware Enter submits the composer

Fixes #810
This commit is contained in:
Bolun Zhang
2026-05-13 17:52:39 +08:00
committed by GitHub
parent 72293f7f4f
commit 81697f85b2
9 changed files with 356 additions and 0 deletions
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
@@ -1598,6 +1599,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
defaultSendBehavior,
isAgentRunning,
});
useIosHardwareKeyboardSubmit({
isEnabled: isInputFocused && !isSendButtonDisabled,
onSubmit: handleDefaultSendAction,
});
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel,
canPressLoadingButton,
@@ -0,0 +1,119 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit";
import {
emitHardwareKeyboardSubmitForTest,
resetHardwareKeyboardSubmitForTest,
getHardwareKeyboardSubmitEnabledForTest,
setHardwareKeyboardSubmitEnabled,
} from "@/native/ios-hardware-keyboard-submit";
describe("useIosHardwareKeyboardSubmit", () => {
beforeEach(() => {
resetHardwareKeyboardSubmitForTest();
});
it("submits when the focused composer receives a hardware keyboard submit event", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: true,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("does not submit when the composer is blurred", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
});
it("does not submit while the default send action is disabled", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not disable native hardware submit when mounted disabled", () => {
setHardwareKeyboardSubmitEnabled(true);
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit: vi.fn(),
}),
);
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
});
it("disables native hardware submit when focus moves away", () => {
const onSubmit = vi.fn();
const { rerender } = renderHook(
({ isEnabled }) =>
useIosHardwareKeyboardSubmit({
isEnabled,
onSubmit,
}),
{ initialProps: { isEnabled: true } },
);
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
rerender({ isEnabled: false });
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
});
it("unsubscribes on unmount", () => {
const onSubmit = vi.fn();
const { unmount } = renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: true,
onSubmit,
}),
);
unmount();
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { useEffect, useRef } from "react";
import {
addHardwareKeyboardSubmitListener,
setHardwareKeyboardSubmitEnabled,
} from "@/native/ios-hardware-keyboard-submit";
interface UseIosHardwareKeyboardSubmitInput {
isEnabled: boolean;
onSubmit: () => void;
}
export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) {
const onSubmitRef = useRef(input.onSubmit);
useEffect(() => {
onSubmitRef.current = input.onSubmit;
}, [input.onSubmit]);
useEffect(() => {
if (!input.isEnabled) {
return;
}
const subscription = addHardwareKeyboardSubmitListener(() => {
onSubmitRef.current();
});
setHardwareKeyboardSubmitEnabled(true);
return () => {
setHardwareKeyboardSubmitEnabled(false);
subscription.remove();
};
}, [input.isEnabled]);
}
@@ -0,0 +1,29 @@
import { requireNativeModule, type EventSubscription } from "expo-modules-core";
type HardwareKeyboardSubmitHandler = () => void;
interface PaseoHardwareKeyboardModule {
setHardwareKeyboardSubmitEnabled(enabled: boolean): void;
addListener(
eventName: "onHardwareKeyboardSubmit",
handler: HardwareKeyboardSubmitHandler,
): EventSubscription;
}
const module = requireNativeModule<PaseoHardwareKeyboardModule>("PaseoHardwareKeyboard");
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
module.setHardwareKeyboardSubmitEnabled(enabled);
}
export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) {
return module.addListener("onHardwareKeyboardSubmit", handler);
}
export function emitHardwareKeyboardSubmitForTest() {}
export function resetHardwareKeyboardSubmitForTest() {}
export function getHardwareKeyboardSubmitEnabledForTest() {
return false;
}
@@ -0,0 +1,34 @@
import type { EventSubscription } from "expo-modules-core";
type HardwareKeyboardSubmitHandler = () => void;
const testHandlers = new Set<HardwareKeyboardSubmitHandler>();
let isEnabledForTest = false;
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
isEnabledForTest = enabled;
}
export function addHardwareKeyboardSubmitListener(
handler: HardwareKeyboardSubmitHandler,
): EventSubscription {
testHandlers.add(handler);
return {
remove: () => {
testHandlers.delete(handler);
},
};
}
export function emitHardwareKeyboardSubmitForTest() {
testHandlers.forEach((handler) => handler());
}
export function resetHardwareKeyboardSubmitForTest() {
testHandlers.clear();
isEnabledForTest = false;
}
export function getHardwareKeyboardSubmitEnabledForTest() {
return isEnabledForTest;
}