diff --git a/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json b/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json new file mode 100644 index 000000000..57759b1d8 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["PaseoHardwareKeyboardModule"], + "reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"] + } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec new file mode 100644 index 000000000..84a8f75c7 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec @@ -0,0 +1,24 @@ +require 'json' + +Pod::Spec.new do |s| + s.name = 'PaseoHardwareKeyboard' + s.version = '0.1.0' + s.summary = 'Hardware keyboard shortcuts for Paseo' + s.description = 'Hardware keyboard shortcuts for Paseo' + s.license = 'AGPL-3.0-or-later' + s.author = 'Paseo' + s.homepage = 'https://paseo.sh' + s.platforms = { :ios => '13.4' } + s.swift_version = '5.4' + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,swift}" +end diff --git a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift new file mode 100644 index 000000000..cd325748e --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift @@ -0,0 +1,99 @@ +import ExpoModulesCore +import UIKit + +private let hardwareSubmitEventName = "onHardwareKeyboardSubmit" + +private weak var activeModule: PaseoHardwareKeyboardModule? +private var isHardwareSubmitEnabled = false + +@objc +public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler { + public override func createRootViewController() -> UIViewController? { + return PaseoHardwareKeyboardRootViewController() + } +} + +public class PaseoHardwareKeyboardModule: Module { + public func definition() -> ModuleDefinition { + Name("PaseoHardwareKeyboard") + + Events(hardwareSubmitEventName) + + OnCreate { + activeModule = self + } + + Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in + DispatchQueue.main.async { + isHardwareSubmitEnabled = enabled + } + } + + OnDestroy { + if activeModule === self { + activeModule = nil + } + isHardwareSubmitEnabled = false + } + } + + fileprivate func emitHardwareKeyboardSubmit() { + sendEvent(hardwareSubmitEventName, [:]) + } +} + +private final class PaseoHardwareKeyboardRootViewController: UIViewController { + override var keyCommands: [UIKeyCommand]? { + guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else { + return super.keyCommands + } + + let command = UIKeyCommand( + input: "\r", + modifierFlags: [], + action: #selector(handleHardwareKeyboardSubmit(_:)) + ) + if #available(iOS 15.0, *) { + command.wantsPriorityOverSystemBehavior = true + } + return (super.keyCommands ?? []) + [command] + } + + @objc + private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) { + guard canSubmitCurrentTextInput() else { + return + } + activeModule?.emitHardwareKeyboardSubmit() + } + + private func canSubmitCurrentTextInput() -> Bool { + guard let responder = UIResponder.paseoCurrentFirstResponder else { + return false + } + guard let textInput = responder as? UITextInput else { + return false + } + return textInput.markedTextRange == nil + } +} + +private extension UIResponder { + private static weak var currentFirstResponder: UIResponder? + + static var paseoCurrentFirstResponder: UIResponder? { + currentFirstResponder = nil + UIApplication.shared.sendAction( + #selector(captureCurrentFirstResponder(_:)), + to: nil, + from: nil, + for: nil + ) + return currentFirstResponder + } + + @objc + private func captureCurrentFirstResponder(_ sender: Any?) { + UIResponder.currentFirstResponder = self + } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/package.json b/packages/app/modules/paseo-hardware-keyboard/package.json new file mode 100644 index 000000000..9431034e8 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/package.json @@ -0,0 +1,5 @@ +{ + "name": "paseo-hardware-keyboard", + "version": "0.1.0", + "private": true +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 242809eb6..d111b248c 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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( defaultSendBehavior, isAgentRunning, }); + useIosHardwareKeyboardSubmit({ + isEnabled: isInputFocused && !isSendButtonDisabled, + onSubmit: handleDefaultSendAction, + }); const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({ submitButtonAccessibilityLabel, canPressLoadingButton, diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx new file mode 100644 index 000000000..86c7c64c5 --- /dev/null +++ b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx @@ -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(); + }); +}); diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts new file mode 100644 index 000000000..b11626315 --- /dev/null +++ b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts @@ -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]); +} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts new file mode 100644 index 000000000..cffff5c40 --- /dev/null +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts @@ -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("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; +} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ts new file mode 100644 index 000000000..0a1833b7f --- /dev/null +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ts @@ -0,0 +1,34 @@ +import type { EventSubscription } from "expo-modules-core"; + +type HardwareKeyboardSubmitHandler = () => void; + +const testHandlers = new Set(); +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; +}