@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["ios"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["PaseoHardwareKeyboardModule"],
|
||||||
|
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "paseo-hardware-keyboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
|
||||||
import { formatShortcut } from "@/utils/format-shortcut";
|
import { formatShortcut } from "@/utils/format-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
@@ -1598,6 +1599,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
defaultSendBehavior,
|
defaultSendBehavior,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
});
|
});
|
||||||
|
useIosHardwareKeyboardSubmit({
|
||||||
|
isEnabled: isInputFocused && !isSendButtonDisabled,
|
||||||
|
onSubmit: handleDefaultSendAction,
|
||||||
|
});
|
||||||
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
|
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
|
||||||
submitButtonAccessibilityLabel,
|
submitButtonAccessibilityLabel,
|
||||||
canPressLoadingButton,
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user